diff --git a/doc/assets/pap-3368/desktop-planning-detail.png b/doc/assets/pap-3368/desktop-planning-detail.png new file mode 100644 index 00000000..aeb69efa Binary files /dev/null and b/doc/assets/pap-3368/desktop-planning-detail.png differ diff --git a/doc/assets/pap-3368/desktop-planning-row.png b/doc/assets/pap-3368/desktop-planning-row.png new file mode 100644 index 00000000..78d6c802 Binary files /dev/null and b/doc/assets/pap-3368/desktop-planning-row.png differ diff --git a/doc/assets/pap-3368/desktop-standard-toggle.png b/doc/assets/pap-3368/desktop-standard-toggle.png new file mode 100644 index 00000000..fa6c25dc Binary files /dev/null and b/doc/assets/pap-3368/desktop-standard-toggle.png differ diff --git a/doc/assets/pap-3368/mobile-planning-detail.png b/doc/assets/pap-3368/mobile-planning-detail.png new file mode 100644 index 00000000..91dd0a19 Binary files /dev/null and b/doc/assets/pap-3368/mobile-planning-detail.png differ diff --git a/doc/assets/pap-3368/mobile-planning-row.png b/doc/assets/pap-3368/mobile-planning-row.png new file mode 100644 index 00000000..65a222fe Binary files /dev/null and b/doc/assets/pap-3368/mobile-planning-row.png differ diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 9de5e06c..19fed607 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -455,6 +455,114 @@ describe("renderPaperclipWakePrompt", () => { expect(prompt).toContain("mark blocked work with the unblock owner/action"); }); + it("renders planning-mode directives for assignment and comment wakes", () => { + const assignmentPrompt = renderPaperclipWakePrompt({ + reason: "issue_assigned", + issue: { + id: "issue-1", + identifier: "PAP-3404", + title: "Plan first", + status: "in_progress", + workMode: "planning", + }, + commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 }, + comments: [], + fallbackFetchNeeded: false, + }); + + expect(assignmentPrompt).toContain("- issue work mode: planning"); + expect(assignmentPrompt).toContain("Make the plan only. Do not write code or perform implementation work."); + + const commentPrompt = renderPaperclipWakePrompt({ + reason: "issue_commented", + issue: { + id: "issue-1", + identifier: "PAP-3404", + title: "Plan first", + status: "in_progress", + workMode: "planning", + }, + commentIds: ["comment-1"], + latestCommentId: "comment-1", + commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 }, + comments: [{ id: "comment-1", body: "Revise the plan" }], + fallbackFetchNeeded: false, + }); + + expect(commentPrompt).toContain("Update the plan only. Do not write code or perform implementation work."); + }); + + it("does not render stale accepted-plan continuation guidance for later planning comment wakes", () => { + const prompt = renderPaperclipWakePrompt({ + reason: "issue_commented", + issue: { + id: "issue-1", + identifier: "PAP-3404", + title: "Plan first", + status: "in_progress", + workMode: "planning", + }, + interactionKind: "request_confirmation", + interactionStatus: "accepted", + commentIds: ["comment-1"], + latestCommentId: "comment-1", + commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 }, + comments: [{ id: "comment-1", body: "Revise the plan" }], + fallbackFetchNeeded: false, + }); + + expect(prompt).toContain("Update the plan only. Do not write code or perform implementation work."); + expect(prompt).not.toContain("accepted-plan continuation"); + expect(prompt).not.toContain("Create child issues from the approved plan only"); + }); + + it("renders accepted-plan continuation guidance for planning issues", () => { + const prompt = renderPaperclipWakePrompt({ + reason: "issue_commented", + issue: { + id: "issue-1", + identifier: "PAP-3404", + title: "Plan first", + status: "in_progress", + workMode: "planning", + }, + interactionKind: "request_confirmation", + interactionStatus: "accepted", + commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 }, + comments: [], + fallbackFetchNeeded: false, + }); + + expect(prompt).toContain("accepted-plan continuation"); + expect(prompt).toContain("Create child issues from the approved plan only"); + expect(prompt).toContain("may create child implementation issues"); + expect(prompt).toContain("must not start implementation work on the planning issue itself"); + }); + + it("keeps accepted-plan guidance when stale comment ids have no loaded comments", () => { + const prompt = renderPaperclipWakePrompt({ + reason: "issue_commented", + issue: { + id: "issue-1", + identifier: "PAP-3404", + title: "Plan first", + status: "in_progress", + workMode: "planning", + }, + interactionKind: "request_confirmation", + interactionStatus: "accepted", + commentIds: ["stale-comment-1"], + latestCommentId: "stale-comment-1", + commentWindow: { requestedCount: 1, includedCount: 0, missingCount: 1 }, + comments: [], + fallbackFetchNeeded: true, + }); + + expect(prompt).toContain("accepted-plan continuation"); + expect(prompt).toContain("Create child issues from the approved plan only"); + expect(prompt).not.toContain("Update the plan only"); + }); + it("renders dependency-blocked interaction guidance", () => { const prompt = renderPaperclipWakePrompt({ reason: "issue_commented", diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 83420182..7fa7c0bb 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -281,6 +281,7 @@ type PaperclipWakeIssue = { identifier: string | null; title: string | null; status: string | null; + workMode: string | null; priority: string | null; }; @@ -366,6 +367,8 @@ type PaperclipWakePayload = { executionStage: PaperclipWakeExecutionStage | null; continuationSummary: PaperclipWakeContinuationSummary | null; livenessContinuation: PaperclipWakeLivenessContinuation | null; + interactionKind: string | null; + interactionStatus: string | null; childIssueSummaries: PaperclipWakeChildIssueSummary[]; childIssueSummaryTruncated: boolean; commentIds: string[]; @@ -384,6 +387,7 @@ function normalizePaperclipWakeIssue(value: unknown): PaperclipWakeIssue | null const identifier = asString(issue.identifier, "").trim() || null; const title = asString(issue.title, "").trim() || null; const status = asString(issue.status, "").trim() || null; + const workMode = asString(issue.workMode, "").trim() || null; const priority = asString(issue.priority, "").trim() || null; if (!id && !identifier && !title) return null; return { @@ -391,6 +395,7 @@ function normalizePaperclipWakeIssue(value: unknown): PaperclipWakeIssue | null identifier, title, status, + workMode, priority, }; } @@ -573,6 +578,8 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl executionStage, continuationSummary, livenessContinuation, + interactionKind: asString(payload.interactionKind, "").trim() || null, + interactionStatus: asString(payload.interactionStatus, "").trim() || null, childIssueSummaries, childIssueSummaryTruncated: asBoolean(payload.childIssueSummaryTruncated, false), commentIds, @@ -592,6 +599,15 @@ export function stringifyPaperclipWakePayload(value: unknown): string | null { return JSON.stringify(normalized); } +export function readPaperclipIssueWorkModeFromContext(value: unknown): string | null { + const context = parseObject(value); + const issue = parseObject(context.paperclipIssue); + const direct = asString(issue.workMode, "").trim(); + if (direct) return direct; + const wake = normalizePaperclipWakePayload(context.paperclipWake); + return wake?.issue?.workMode ?? null; +} + export function renderPaperclipWakePrompt( value: unknown, options: { resumedSession?: boolean } = {}, @@ -644,9 +660,31 @@ export function renderPaperclipWakePrompt( if (normalized.issue?.status) { lines.push(`- issue status: ${normalized.issue.status}`); } + if (normalized.issue?.workMode) { + lines.push(`- issue work mode: ${normalized.issue.workMode}`); + } if (normalized.issue?.priority) { lines.push(`- issue priority: ${normalized.issue.priority}`); } + if (normalized.issue?.workMode === "planning") { + const hasWakeComments = normalized.comments.length > 0; + const acceptedPlanContinuation = + !hasWakeComments && + normalized.interactionKind === "request_confirmation" && normalized.interactionStatus === "accepted"; + let directive = "Make the plan only. Do not write code or perform implementation work."; + if (hasWakeComments) { + directive = "Update the plan only. Do not write code or perform implementation work."; + } + if (acceptedPlanContinuation) { + directive = "Create child issues from the approved plan only. Do not write code or perform implementation work on the planning issue."; + } + lines.push(`- planning directive: ${directive}`); + if (acceptedPlanContinuation) { + lines.push( + "- accepted-plan continuation: you may create child implementation issues from the approved plan, but must not start implementation work on the planning issue itself", + ); + } + } if (normalized.checkedOutByHarness) { lines.push("- checkout: already claimed by the harness for this run"); } diff --git a/packages/adapters/acpx-local/src/server/execute.ts b/packages/adapters/acpx-local/src/server/execute.ts index 7d9080c6..3ef832b6 100644 --- a/packages/adapters/acpx-local/src/server/execute.ts +++ b/packages/adapters/acpx-local/src/server/execute.ts @@ -18,6 +18,7 @@ import { materializePaperclipSkillCopy, parseObject, readPaperclipRuntimeSkillEntries, + readPaperclipIssueWorkModeFromContext, renderPaperclipWakePrompt, renderTemplate, resolvePaperclipDesiredSkillNames, @@ -686,7 +687,9 @@ async function buildRuntime(input: { ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) : []; const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); + const issueWorkMode = readPaperclipIssueWorkModeFromContext(context); if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; + if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode; if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 19daae24..7c671fc6 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -30,6 +30,7 @@ import { applyPaperclipWorkspaceEnv, buildPaperclipEnv, readPaperclipRuntimeSkillEntries, + readPaperclipIssueWorkModeFromContext, joinPromptSections, buildInvocationEnvForLogs, ensureAbsoluteDirectory, @@ -191,10 +192,14 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise typeof value === "string" && value.trim().length > 0) : []; const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); + const issueWorkMode = readPaperclipIssueWorkModeFromContext(context); if (wakeTaskId) { env.PAPERCLIP_TASK_ID = wakeTaskId; } + if (issueWorkMode) { + env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode; + } if (wakeReason) { env.PAPERCLIP_WAKE_REASON = wakeReason; } diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 0c18406f..a8d8c174 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -28,6 +28,7 @@ import { ensurePaperclipSkillSymlink, ensurePathInEnv, readPaperclipRuntimeSkillEntries, + readPaperclipIssueWorkModeFromContext, resolvePaperclipDesiredSkillNames, renderTemplate, renderPaperclipWakePrompt, @@ -423,9 +424,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof value === "string" && value.trim().length > 0) : []; const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); + const issueWorkMode = readPaperclipIssueWorkModeFromContext(context); if (wakeTaskId) { env.PAPERCLIP_TASK_ID = wakeTaskId; } + if (issueWorkMode) { + env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode; + } if (wakeReason) { env.PAPERCLIP_WAKE_REASON = wakeReason; } diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 6611fafa..cadd83e2 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -33,6 +33,7 @@ import { ensurePaperclipSkillSymlink, ensurePathInEnv, readPaperclipRuntimeSkillEntries, + readPaperclipIssueWorkModeFromContext, resolvePaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, renderTemplate, @@ -268,9 +269,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof value === "string" && value.trim().length > 0) : []; const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); + const issueWorkMode = readPaperclipIssueWorkModeFromContext(context); if (wakeTaskId) { env.PAPERCLIP_TASK_ID = wakeTaskId; } + if (issueWorkMode) { + env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode; + } if (wakeReason) { env.PAPERCLIP_WAKE_REASON = wakeReason; } diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index cbac9735..6141249a 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -35,6 +35,7 @@ import { joinPromptSections, ensurePathInEnv, readPaperclipRuntimeSkillEntries, + readPaperclipIssueWorkModeFromContext, resolvePaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, parseObject, @@ -244,7 +245,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof value === "string" && value.trim().length > 0) : []; const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); + const issueWorkMode = readPaperclipIssueWorkModeFromContext(context); if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; + if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode; if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index 23ceaeb2..f019bfa8 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -8,6 +8,7 @@ import { asString, buildPaperclipEnv, parseObject, + readPaperclipIssueWorkModeFromContext, renderPaperclipWakePrompt, stringifyPaperclipWakePayload, } from "@paperclipai/adapter-utils/server-utils"; @@ -347,6 +348,8 @@ function buildPaperclipEnvForWake(ctx: AdapterExecutionContext, wakePayload: Wak paperclipEnv.PAPERCLIP_API_URL = paperclipApiUrlOverride; } if (wakePayload.taskId) paperclipEnv.PAPERCLIP_TASK_ID = wakePayload.taskId; + const issueWorkMode = readPaperclipIssueWorkModeFromContext(ctx.context); + if (issueWorkMode) paperclipEnv.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode; if (wakePayload.wakeReason) paperclipEnv.PAPERCLIP_WAKE_REASON = wakePayload.wakeReason; if (wakePayload.wakeCommentId) paperclipEnv.PAPERCLIP_WAKE_COMMENT_ID = wakePayload.wakeCommentId; if (wakePayload.approvalId) paperclipEnv.PAPERCLIP_APPROVAL_ID = wakePayload.approvalId; diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 08fabe69..55511b0a 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -40,6 +40,7 @@ import { DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, runChildProcess, readPaperclipRuntimeSkillEntries, + readPaperclipIssueWorkModeFromContext, resolvePaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js"; @@ -266,7 +267,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof value === "string" && value.trim().length > 0) : []; const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); + const issueWorkMode = readPaperclipIssueWorkModeFromContext(context); if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; + if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode; if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index a89332ae..e6c0769f 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -34,6 +34,7 @@ import { ensurePaperclipSkillSymlink, ensurePathInEnv, readPaperclipRuntimeSkillEntries, + readPaperclipIssueWorkModeFromContext, resolvePaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, renderTemplate, @@ -295,8 +296,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof value === "string" && value.trim().length > 0) : []; const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); + const issueWorkMode = readPaperclipIssueWorkModeFromContext(context); if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; + if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode; if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; diff --git a/packages/db/src/migrations/0081_optimal_dormammu.sql b/packages/db/src/migrations/0081_optimal_dormammu.sql new file mode 100644 index 00000000..7becbcb9 --- /dev/null +++ b/packages/db/src/migrations/0081_optimal_dormammu.sql @@ -0,0 +1 @@ +ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "work_mode" text DEFAULT 'standard' NOT NULL; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0076_snapshot.json b/packages/db/src/migrations/meta/0081_snapshot.json similarity index 98% rename from packages/db/src/migrations/meta/0076_snapshot.json rename to packages/db/src/migrations/meta/0081_snapshot.json index d002c177..3342eaaf 100644 --- a/packages/db/src/migrations/meta/0076_snapshot.json +++ b/packages/db/src/migrations/meta/0081_snapshot.json @@ -1,6 +1,6 @@ { - "id": "063c8887-ed46-4125-a08f-51c16b636245", - "prevId": "fdc9cd8b-5423-4d64-b255-9bc1497fdd6a", + "id": "a7ba5d6c-9f74-487d-a9c1-56a4d5455b92", + "prevId": "50cf2dfe-df7b-4f02-a169-edbae599cf39", "version": "7", "dialect": "postgresql", "tables": { @@ -8274,6 +8274,12 @@ "primaryKey": false, "notNull": false }, + "author_type": { + "name": "author_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, "created_by_run_id": { "name": "created_by_run_id", "type": "uuid", @@ -8286,6 +8292,18 @@ "primaryKey": false, "notNull": true }, + "presentation": { + "name": "presentation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -10990,6 +11008,13 @@ "notNull": true, "default": "'backlog'" }, + "work_mode": { + "name": "work_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, "priority": { "name": "priority", "type": "text", @@ -13103,6 +13128,195 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.plugin_managed_resources": { + "name": "plugin_managed_resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_key": { + "name": "plugin_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_kind": { + "name": "resource_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_key": { + "name": "resource_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "defaults_json": { + "name": "defaults_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_managed_resources_company_idx": { + "name": "plugin_managed_resources_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_managed_resources_plugin_idx": { + "name": "plugin_managed_resources_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_managed_resources_resource_idx": { + "name": "plugin_managed_resources_resource_idx", + "columns": [ + { + "expression": "resource_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_managed_resources_company_plugin_resource_uq": { + "name": "plugin_managed_resources_company_plugin_resource_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_managed_resources_company_id_companies_id_fk": { + "name": "plugin_managed_resources_company_id_companies_id_fk", + "tableFrom": "plugin_managed_resources", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_managed_resources_plugin_id_plugins_id_fk": { + "name": "plugin_managed_resources_plugin_id_plugins_id_fk", + "tableFrom": "plugin_managed_resources", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.plugin_migrations": { "name": "plugin_migrations", "schema": "", @@ -14358,6 +14572,214 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.routine_revisions": { + "name": "routine_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "restored_from_revision_id": { + "name": "restored_from_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routine_revisions_routine_revision_uq": { + "name": "routine_revisions_routine_revision_uq", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revision_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_revisions_company_routine_created_idx": { + "name": "routine_revisions_company_routine_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_revisions_company_id_companies_id_fk": { + "name": "routine_revisions_company_id_companies_id_fk", + "tableFrom": "routine_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_revisions_routine_id_routines_id_fk": { + "name": "routine_revisions_routine_id_routines_id_fk", + "tableFrom": "routine_revisions", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_revisions_restored_from_revision_id_routine_revisions_id_fk": { + "name": "routine_revisions_restored_from_revision_id_routine_revisions_id_fk", + "tableFrom": "routine_revisions", + "tableTo": "routine_revisions", + "columnsFrom": [ + "restored_from_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routine_revisions_created_by_agent_id_agents_id_fk": { + "name": "routine_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "routine_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routine_revisions_created_by_run_id_heartbeat_runs_id_fk": { + "name": "routine_revisions_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "routine_revisions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.routine_runs": { "name": "routine_runs", "schema": "", @@ -15022,6 +15444,19 @@ "notNull": true, "default": "'[]'::jsonb" }, + "latest_revision_id": { + "name": "latest_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, "created_by_agent_id": { "name": "created_by_agent_id", "type": "uuid", @@ -15929,195 +16364,6 @@ "policies": {}, "checkConstraints": {}, "isRLSEnabled": false - }, - "public.plugin_managed_resources": { - "name": "plugin_managed_resources", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "company_id": { - "name": "company_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "plugin_key": { - "name": "plugin_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "resource_kind": { - "name": "resource_kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "resource_key": { - "name": "resource_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "resource_id": { - "name": "resource_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "defaults_json": { - "name": "defaults_json", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "plugin_managed_resources_company_idx": { - "name": "plugin_managed_resources_company_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "plugin_managed_resources_plugin_idx": { - "name": "plugin_managed_resources_plugin_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "plugin_managed_resources_resource_idx": { - "name": "plugin_managed_resources_resource_idx", - "columns": [ - { - "expression": "resource_kind", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "resource_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "plugin_managed_resources_company_plugin_resource_uq": { - "name": "plugin_managed_resources_company_plugin_resource_uq", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "resource_kind", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "resource_key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "plugin_managed_resources_company_id_companies_id_fk": { - "name": "plugin_managed_resources_company_id_companies_id_fk", - "tableFrom": "plugin_managed_resources", - "tableTo": "companies", - "columnsFrom": [ - "company_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "plugin_managed_resources_plugin_id_plugins_id_fk": { - "name": "plugin_managed_resources_plugin_id_plugins_id_fk", - "tableFrom": "plugin_managed_resources", - "tableTo": "plugins", - "columnsFrom": [ - "plugin_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false } }, "enums": {}, diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 8ad509cb..418bb6e6 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -568,6 +568,13 @@ "when": 1777849000000, "tag": "0080_company_search_fuzzystrmatch", "breakpoints": true + }, + { + "idx": 81, + "version": "7", + "when": 1778067785040, + "tag": "0081_optimal_dormammu", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/issues.ts b/packages/db/src/schema/issues.ts index e848afb5..9f899f25 100644 --- a/packages/db/src/schema/issues.ts +++ b/packages/db/src/schema/issues.ts @@ -30,6 +30,7 @@ export const issues = pgTable( title: text("title").notNull(), description: text("description"), status: text("status").notNull().default("backlog"), + workMode: text("work_mode").notNull().default("standard"), priority: text("priority").notNull().default("medium"), assigneeAgentId: uuid("assignee_agent_id").references(() => agents.id), assigneeUserId: text("assignee_user_id"), diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 6961c929..843f479b 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -852,6 +852,7 @@ export interface WorkerToHostMethods { title: string; description?: string; status?: string; + workMode?: string; priority?: string; assigneeAgentId?: string; assigneeUserId?: string | null; diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index cfbadd79..fdc81f1c 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -1147,6 +1147,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { title: input.title, description: input.description ?? null, status: input.status ?? "todo", + workMode: input.workMode ?? "standard", priority: input.priority ?? "medium", assigneeAgentId: input.assigneeAgentId ?? null, assigneeUserId: input.assigneeUserId ?? null, diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index f4a946f9..a7b7a168 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -1257,6 +1257,7 @@ export interface PluginIssuesClient { title: string; description?: string; status?: Issue["status"]; + workMode?: Issue["workMode"]; priority?: Issue["priority"]; assigneeAgentId?: string; assigneeUserId?: string | null; @@ -1280,6 +1281,7 @@ export interface PluginIssuesClient { | "title" | "description" | "status" + | "workMode" | "priority" | "assigneeAgentId" | "assigneeUserId" diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index 7683605d..ad578d60 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -714,6 +714,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost title: input.title, description: input.description, status: input.status, + workMode: input.workMode, priority: input.priority, assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 7a63c439..12cf3b0a 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -146,6 +146,8 @@ export const INBOX_MINE_ISSUE_STATUS_FILTER = INBOX_MINE_ISSUE_STATUSES.join("," export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const; export type IssuePriority = (typeof ISSUE_PRIORITIES)[number]; +export const ISSUE_WORK_MODES = ["standard", "planning"] as const; +export type IssueWorkMode = (typeof ISSUE_WORK_MODES)[number]; export const MAX_ISSUE_REQUEST_DEPTH = 1024; export const ISSUE_COMMENT_AUTHOR_TYPES = ["user", "agent", "system"] as const; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d4e63897..4c06b736 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -19,6 +19,7 @@ export { INBOX_MINE_ISSUE_STATUSES, INBOX_MINE_ISSUE_STATUS_FILTER, ISSUE_PRIORITIES, + ISSUE_WORK_MODES, MAX_ISSUE_REQUEST_DEPTH, ISSUE_COMMENT_AUTHOR_TYPES, ISSUE_COMMENT_METADATA_ROW_TYPES, @@ -134,6 +135,7 @@ export { type AgentIconName, type IssueStatus, type IssuePriority, + type IssueWorkMode, type IssueCommentAuthorType, type IssueCommentMetadataRowType, type IssueCommentPresentationKind, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index a722991e..c0c0f1f4 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -144,6 +144,7 @@ export type { } from "./work-product.js"; export type { Issue, + IssueWorkMode, IssueAssigneeAdapterOverrides, IssueBlockerAttention, IssueBlockerAttentionReason, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index dd19f37f..59d9f169 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -15,6 +15,7 @@ import type { IssueExecutionStateStatus, IssueOriginKind, IssuePriority, + IssueWorkMode, ModelProfileKey, IssueThreadInteractionContinuationPolicy, IssueThreadInteractionKind, @@ -26,6 +27,8 @@ import type { Project, ProjectWorkspace } from "./project.js"; import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings } from "./workspace-runtime.js"; import type { IssueWorkProduct } from "./work-product.js"; +export type { IssueWorkMode }; + export interface IssueAncestorProject { id: string; name: string; @@ -302,6 +305,7 @@ export interface Issue { title: string; description: string | null; status: IssueStatus; + workMode: IssueWorkMode; priority: IssuePriority; assigneeAgentId: string | null; assigneeUserId: string | null; @@ -454,6 +458,7 @@ export interface SuggestedTaskDraft { title: string; description?: string | null; priority?: IssuePriority | null; + workMode?: IssueWorkMode | null; assigneeAgentId?: string | null; assigneeUserId?: string | null; projectId?: string | null; diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 6b973f19..ad5fbc53 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -127,6 +127,26 @@ describe("issue validators", () => { expect(parsed.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH); }); + it("defaults issue work mode to standard and accepts planning", () => { + expect(createIssueSchema.parse({ title: "Plan first" }).workMode).toBe("standard"); + expect(createIssueSchema.parse({ title: "Plan first", workMode: "planning" }).workMode).toBe("planning"); + expect(updateIssueSchema.parse({ workMode: "planning" }).workMode).toBe("planning"); + expect(suggestedTaskDraftSchema.parse({ + clientKey: "planning-child", + title: "Plan child", + workMode: "planning", + }).workMode).toBe("planning"); + }); + + it("rejects unknown issue work modes", () => { + expect(createIssueSchema.safeParse({ title: "Plan first", workMode: "normal" }).success).toBe(false); + expect(suggestedTaskDraftSchema.safeParse({ + clientKey: "bad-child", + title: "Bad child", + workMode: "analysis", + }).success).toBe(false); + }); + it("clamps oversized requestDepth values on update", () => { const parsed = updateIssueSchema.parse({ requestDepth: MAX_ISSUE_REQUEST_DEPTH + 1, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index b45fd8c1..d1f9af21 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -14,6 +14,7 @@ import { ISSUE_COMMENT_PRESENTATION_TONES, ISSUE_MONITOR_SCHEDULED_BY, ISSUE_PRIORITIES, + ISSUE_WORK_MODES, clampIssueRequestDepth, ISSUE_STATUSES, ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES, @@ -182,6 +183,7 @@ export const createIssueSchema = z.object({ title: z.string().min(1), description: multilineTextSchema.optional().nullable(), status: z.enum(ISSUE_STATUSES).optional().default("backlog"), + workMode: z.enum(ISSUE_WORK_MODES).optional().default("standard"), priority: z.enum(ISSUE_PRIORITIES).optional().default("medium"), assigneeAgentId: z.string().uuid().optional().nullable(), assigneeUserId: z.string().optional().nullable(), @@ -353,6 +355,7 @@ export const suggestedTaskDraftSchema = z.object({ title: z.string().trim().min(1).max(240), description: multilineTextSchema.pipe(z.string().trim().max(20000)).nullable().optional(), priority: z.enum(ISSUE_PRIORITIES).nullable().optional(), + workMode: z.enum(ISSUE_WORK_MODES).nullable().optional(), assigneeAgentId: z.string().uuid().nullable().optional(), assigneeUserId: z.string().trim().min(1).nullable().optional(), projectId: z.string().uuid().nullable().optional(), diff --git a/server/src/__tests__/heartbeat-context-summary.test.ts b/server/src/__tests__/heartbeat-context-summary.test.ts index c0d6a423..674ccecb 100644 --- a/server/src/__tests__/heartbeat-context-summary.test.ts +++ b/server/src/__tests__/heartbeat-context-summary.test.ts @@ -1,9 +1,133 @@ import { describe, expect, it } from "vitest"; import { + buildPaperclipTaskMarkdown, + mergeCoalescedContextSnapshot, summarizeHeartbeatRunContextSnapshot, summarizeHeartbeatRunListResultJson, } from "../services/heartbeat.js"; +describe("buildPaperclipTaskMarkdown", () => { + it("adds planning directives for assignment and comment task context", () => { + const assignment = buildPaperclipTaskMarkdown({ + issue: { + id: "issue-1", + identifier: "PAP-3404", + title: "Plan first", + workMode: "planning", + description: null, + }, + }); + + expect(assignment).toContain("- Work mode: \"planning\""); + expect(assignment).toContain("Make the plan only. Do not write code or perform implementation work."); + + const commentWake = buildPaperclipTaskMarkdown({ + issue: { + id: "issue-1", + identifier: "PAP-3404", + title: "Plan first", + workMode: "planning", + description: null, + }, + wakeComment: { + id: "comment-1", + body: "Please revise the plan.", + }, + }); + + expect(commentWake).toContain("Update the plan only. Do not write code or perform implementation work."); + + const acceptedConfirmation = buildPaperclipTaskMarkdown({ + issue: { + id: "issue-1", + identifier: "PAP-3404", + title: "Plan first", + workMode: "planning", + description: null, + }, + interaction: { + kind: "request_confirmation", + status: "accepted", + }, + }); + + expect(acceptedConfirmation).toContain("Create child issues from the approved plan only"); + expect(acceptedConfirmation).not.toContain("Make the plan only."); + }); + + it("prefers ordinary comment planning guidance over stale accepted confirmation state", () => { + const commentWake = buildPaperclipTaskMarkdown({ + issue: { + id: "issue-1", + identifier: "PAP-3404", + title: "Plan first", + workMode: "planning", + description: null, + }, + wakeComment: { + id: "comment-1", + body: "Please revise the plan.", + }, + interaction: { + kind: "request_confirmation", + status: "accepted", + }, + }); + + expect(commentWake).toContain("Update the plan only. Do not write code or perform implementation work."); + expect(commentWake).not.toContain("Create child issues from the approved plan only"); + }); +}); + +describe("mergeCoalescedContextSnapshot", () => { + it("clears stale accepted-plan interaction state when merging a later ordinary comment wake", () => { + const merged = mergeCoalescedContextSnapshot( + { + issueId: "issue-1", + interactionId: "interaction-1", + interactionKind: "request_confirmation", + interactionStatus: "accepted", + continuationPolicy: "wake_assignee_on_accept", + wakeReason: "issue_commented", + }, + { + issueId: "issue-1", + commentId: "comment-1", + wakeCommentId: "comment-1", + wakeReason: "issue_commented", + }, + ); + + expect(merged.interactionId).toBeUndefined(); + expect(merged.interactionKind).toBeUndefined(); + expect(merged.interactionStatus).toBeUndefined(); + expect(merged.continuationPolicy).toBeUndefined(); + expect(merged.commentId).toBe("comment-1"); + expect(merged.wakeCommentId).toBe("comment-1"); + }); + + it("preserves accepted-plan interaction state for the interaction wake itself", () => { + const merged = mergeCoalescedContextSnapshot( + { + issueId: "issue-1", + }, + { + issueId: "issue-1", + interactionId: "interaction-1", + interactionKind: "request_confirmation", + interactionStatus: "accepted", + continuationPolicy: "wake_assignee_on_accept", + wakeReason: "issue_commented", + }, + ); + + expect(merged.interactionId).toBe("interaction-1"); + expect(merged.interactionKind).toBe("request_confirmation"); + expect(merged.interactionStatus).toBe("accepted"); + expect(merged.continuationPolicy).toBe("wake_assignee_on_accept"); + }); +}); + describe("summarizeHeartbeatRunContextSnapshot", () => { it("keeps only the small retry/linking fields needed by the client", () => { const summarized = summarizeHeartbeatRunContextSnapshot({ diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 4ee44941..f1f0be38 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { agents, @@ -110,6 +111,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { { clientKey: "root", title: "Create the root follow-up", + workMode: "planning", assigneeAgentId, }, { @@ -153,6 +155,19 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { status: "todo", }), ]); + const createdIssueRows = await db + .select({ + title: issues.title, + workMode: issues.workMode, + }) + .from(issues) + .where(eq(issues.companyId, companyId)); + expect(createdIssueRows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: "Create the root follow-up", workMode: "planning" }), + expect.objectContaining({ title: "Create the nested follow-up", workMode: "standard" }), + ]), + ); const children = await issuesSvc.list(companyId, { parentId: issueId }); expect(children).toHaveLength(1); diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts index 30ec5edc..4e90e0ad 100644 --- a/server/src/__tests__/issues-goal-context-routes.test.ts +++ b/server/src/__tests__/issues-goal-context-routes.test.ts @@ -147,6 +147,7 @@ const legacyProjectLinkedIssue = { title: "Legacy onboarding task", description: "Seed the first CEO task", status: "todo", + workMode: "planning", priority: "medium", projectId: "22222222-2222-4222-8222-222222222222", goalId: null, @@ -264,6 +265,7 @@ describe.sequential("issue goal context routes", () => { expect(res.status).toBe(200); expect(res.body.issue.goalId).toBe(projectGoal.id); + expect(res.body.issue.workMode).toBe("planning"); expect(res.body.goal).toEqual( expect.objectContaining({ id: projectGoal.id, diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 0b23ed51..0dc53d1a 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1304,6 +1304,7 @@ export function issueRoutes( title: issue.title, description: issue.description, status: issue.status, + workMode: issue.workMode, ...(blockerAttention ? { blockerAttention } : {}), productivityReview, priority: issue.priority, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 32c04689..427f91cd 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1782,6 +1782,7 @@ function enrichWakeContextSnapshot(input: { contextSnapshot.wakeTriggerDetail = triggerDetail; } normalizeModelProfileWakeContext({ contextSnapshot, payload }); + normalizeInteractionContinuationWakeContext(contextSnapshot, payload); return { contextSnapshot, @@ -1792,6 +1793,35 @@ function enrichWakeContextSnapshot(input: { }; } +const INTERACTION_CONTINUATION_CONTEXT_KEYS = [ + "interactionId", + "interactionKind", + "interactionStatus", + "continuationPolicy", +] as const; + +function isInteractionResolutionWakePayload(payload: Record | null | undefined) { + return readNonEmptyString(payload?.mutation) === "interaction"; +} + +function clearInteractionContinuationWakeContext(contextSnapshot: Record) { + for (const key of INTERACTION_CONTINUATION_CONTEXT_KEYS) { + delete contextSnapshot[key]; + } +} + +function hasInteractionContinuationWakeContext(contextSnapshot: Record) { + return INTERACTION_CONTINUATION_CONTEXT_KEYS.some((key) => readNonEmptyString(contextSnapshot[key])); +} + +function normalizeInteractionContinuationWakeContext( + contextSnapshot: Record, + payload: Record | null | undefined, +) { + if (isInteractionResolutionWakePayload(payload)) return; + clearInteractionContinuationWakeContext(contextSnapshot); +} + export function mergeCoalescedContextSnapshot( existingRaw: unknown, incoming: Record, @@ -1811,6 +1841,9 @@ export function mergeCoalescedContextSnapshot( // regenerate any structured payload from those ids. delete merged[PAPERCLIP_WAKE_PAYLOAD_KEY]; } + if (!hasInteractionContinuationWakeContext(incoming)) { + clearInteractionContinuationWakeContext(merged); + } return merged; } @@ -1833,6 +1866,7 @@ async function buildPaperclipWakePayload(input: { title: string; status: string; priority: string; + workMode: string; } | null; }) { @@ -1850,6 +1884,7 @@ async function buildPaperclipWakePayload(input: { title: issues.title, status: issues.status, priority: issues.priority, + workMode: issues.workMode, }) .from(issues) .where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId))) @@ -1936,6 +1971,7 @@ async function buildPaperclipWakePayload(input: { title: issueSummary.title, status: issueSummary.status, priority: issueSummary.priority, + workMode: issueSummary.workMode, } : null, childIssueSummaries: Array.isArray(input.contextSnapshot.childIssueSummaries) @@ -1955,6 +1991,8 @@ async function buildPaperclipWakePayload(input: { instruction: readNonEmptyString(input.contextSnapshot.livenessContinuationInstruction), } : null, + interactionKind: readNonEmptyString(input.contextSnapshot.interactionKind), + interactionStatus: readNonEmptyString(input.contextSnapshot.interactionStatus), checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true, dependencyBlockedInteraction: input.contextSnapshot.dependencyBlockedInteraction === true, treeHoldInteraction: input.contextSnapshot.treeHoldInteraction === true, @@ -2016,12 +2054,17 @@ export function buildPaperclipTaskMarkdown(input: { id: string; identifier: string | null; title: string; + workMode?: string | null; description?: string | null; } | null; wakeComment?: { id: string; body: string; } | null; + interaction?: { + kind?: string | null; + status?: string | null; + } | null; }) { const quoteTaskScalar = (value: string) => JSON.stringify(value); const fenceTaskText = (value: string) => { @@ -2034,6 +2077,10 @@ export function buildPaperclipTaskMarkdown(input: { }; const issue = input.issue; const wakeComment = input.wakeComment ?? null; + const acceptedPlanContinuation = + !wakeComment && + input.interaction?.kind === "request_confirmation" && + input.interaction.status === "accepted"; if (!issue && !wakeComment) return null; const lines = [ @@ -2045,6 +2092,21 @@ export function buildPaperclipTaskMarkdown(input: { `- Issue: ${quoteTaskScalar(issue.identifier || issue.id)}`, `- Title: ${quoteTaskScalar(issue.title)}`, ); + if (issue.workMode === "planning") { + let directive = "Make the plan only. Do not write code or perform implementation work."; + if (wakeComment) { + directive = "Update the plan only. Do not write code or perform implementation work."; + } + if (acceptedPlanContinuation) { + directive = "Create child issues from the approved plan only. Do not write code or perform implementation work on the planning issue."; + } + lines.push( + `- Work mode: ${quoteTaskScalar("planning")}`, + "", + "Planning mode directive:", + directive, + ); + } const description = issue.description?.trim(); if (description) { lines.push("", "Issue description:", fenceTaskText(description)); @@ -2328,6 +2390,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) title: issues.title, description: issues.description, status: issues.status, + workMode: issues.workMode, priority: issues.priority, projectId: issues.projectId, projectWorkspaceId: issues.projectWorkspaceId, @@ -6564,6 +6627,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) title: issueContext.title, status: issueContext.status, priority: issueContext.priority, + workMode: issueContext.workMode, description: issueContext.description, projectId: issueContext.projectId, projectWorkspaceId: issueContext.projectWorkspaceId, @@ -6596,6 +6660,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) title: issueRef.title, status: issueRef.status, priority: issueRef.priority, + workMode: issueRef.workMode, } : null, }); @@ -6610,10 +6675,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) id: issueRef.id, identifier: issueRef.identifier, title: issueRef.title, + workMode: issueRef.workMode, description: issueRef.description, } : null, wakeComment: wakeCommentContext, + interaction: { + kind: readNonEmptyString(context.interactionKind), + status: readNonEmptyString(context.interactionStatus), + }, }); if (issueRef) { context.paperclipIssue = { @@ -6621,6 +6691,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) identifier: issueRef.identifier, title: issueRef.title, description: issueRef.description, + workMode: issueRef.workMode, }; } else { delete context.paperclipIssue; @@ -8764,7 +8835,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) if (coalescedTargetRun) { const mergedContextSnapshot = mergeCoalescedContextSnapshot( coalescedTargetRun.contextSnapshot, - contextSnapshot, + enrichedContextSnapshot, ); const mergedRun = await db .update(heartbeatRuns) diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index de6e1654..80b31c11 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -839,6 +839,7 @@ export function issueThreadInteractionService(db: Db) { title: task.title, description: task.description ?? null, status: "todo", + workMode: task.workMode ?? "standard", priority: task.priority ?? "medium", assigneeAgentId: task.assigneeAgentId ?? null, assigneeUserId: task.assigneeUserId ?? null, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 59cb3e8c..332fe924 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1430,6 +1430,7 @@ const issueListSelect = { END `, status: issues.status, + workMode: issues.workMode, priority: issues.priority, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index b288aa85..b601f495 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -51,6 +51,7 @@ export interface ExecutionWorkspaceIssueRef { id: string; identifier: string | null; title: string | null; + workMode?: string | null; } export interface ExecutionWorkspaceAgentRef { @@ -712,6 +713,7 @@ function buildWorkspaceCommandEnv(input: { env.PAPERCLIP_ISSUE_ID = input.issue?.id ?? ""; env.PAPERCLIP_ISSUE_IDENTIFIER = input.issue?.identifier ?? ""; env.PAPERCLIP_ISSUE_TITLE = input.issue?.title ?? ""; + env.PAPERCLIP_ISSUE_WORK_MODE = input.issue?.workMode ?? ""; return env; } diff --git a/tests/e2e/planning-mode-visual-verification.spec.ts b/tests/e2e/planning-mode-visual-verification.spec.ts new file mode 100644 index 00000000..3e6e2444 --- /dev/null +++ b/tests/e2e/planning-mode-visual-verification.spec.ts @@ -0,0 +1,157 @@ +import { expect, test } from "@playwright/test"; + +const SKIP_LLM = process.env.PAPERCLIP_E2E_SKIP_LLM !== "false"; + +const AGENT_NAME = "CEO"; +const TASK_TITLE = "PAP-3413 planning mode evidence"; + +test("captures planning mode UI for desktop and mobile", async ({ page }) => { + const timestamp = Date.now(); + const companyName = `PAP-3413-${timestamp}`; + const screenshotDir = "test-results/planning-mode"; + + await page.goto("/onboarding"); + await expect(page.locator("h3", { hasText: "Name your company" })).toBeVisible({ timeout: 5_000 }); + + await page.locator('input[placeholder="Acme Corp"]').fill(companyName); + await page.getByRole("button", { name: "Next" }).click(); + + await expect(page.locator("h3", { hasText: "Create your first agent" })).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('input[placeholder="CEO"]')).toHaveValue(AGENT_NAME); + await page.getByRole("button", { name: "Next" }).click(); + + await expect(page.locator("h3", { hasText: "Give it something to do" })).toBeVisible({ timeout: 30_000 }); + const baseUrl = page.url().split("/").slice(0, 3).join("/"); + + if (SKIP_LLM) { + const companiesAfterAgentRes = await page.request.get(`${baseUrl}/api/companies`); + expect(companiesAfterAgentRes.ok()).toBe(true); + const companiesAfterAgent = await companiesAfterAgentRes.json(); + const companyAfterAgent = companiesAfterAgent.find((c: { name: string }) => c.name === companyName); + expect(companyAfterAgent).toBeTruthy(); + + const agentsAfterCreateRes = await page.request.get(`${baseUrl}/api/companies/${companyAfterAgent.id}/agents`); + expect(agentsAfterCreateRes.ok()).toBe(true); + const agentsAfterCreate = await agentsAfterCreateRes.json(); + const ceoAgentAfterCreate = agentsAfterCreate.find((a: { name: string }) => a.name === AGENT_NAME); + expect(ceoAgentAfterCreate).toBeTruthy(); + + const disableWakeRes = await page.request.patch( + `${baseUrl}/api/agents/${ceoAgentAfterCreate.id}?companyId=${encodeURIComponent(companyAfterAgent.id)}`, + { + data: { + runtimeConfig: { + heartbeat: { + enabled: false, + intervalSec: 300, + wakeOnDemand: false, + cooldownSec: 10, + maxConcurrentRuns: 5, + }, + }, + }, + }, + ); + expect(disableWakeRes.ok()).toBe(true); + } + + const taskTitleInput = page.locator('input[placeholder="e.g. Research competitor pricing"]'); + await taskTitleInput.clear(); + await taskTitleInput.fill(TASK_TITLE); + await page.getByRole("button", { name: "Next" }).click(); + + await expect(page.locator("h3", { hasText: "Ready to launch" })).toBeVisible({ timeout: 30_000 }); + await page.getByRole("button", { name: "Create & Open Issue" }).click(); + await expect(page).toHaveURL(/\/issues\//, { timeout: 30_000 }); + + const openedIssueUrl = page.url(); + const openedIssueIdentifier = openedIssueUrl.split("/").filter(Boolean).pop(); + const baseOrigin = new URL(openedIssueUrl).origin; + const companyRes = await page.request.get(`${baseOrigin}/api/companies`); + expect(companyRes.ok()).toBe(true); + const companies = await companyRes.json(); + const company = companies.find((c: { name: string }) => c.name === companyName); + expect(company).toBeTruthy(); + const issueRes = await page.request.get(`${baseOrigin}/api/companies/${company.id}/issues`); + expect(issueRes.ok()).toBe(true); + const issues = await issueRes.json(); + const planningSeedIssue = issues.find( + (candidate: { id: string; identifier?: string; title: string }) => + candidate.identifier === openedIssueIdentifier || candidate.id === openedIssueIdentifier || candidate.title === TASK_TITLE, + ); + expect(planningSeedIssue).toBeTruthy(); + + const issue = planningSeedIssue; + const issueIdentifier = issue.identifier ?? issue.id; + const issuePath = `/${company.issuePrefix ?? company.id}/issues/${issueIdentifier}`; + const companyPrefix = company.issuePrefix ?? company.id; + const issueLinkSelector = `a[href$="/issues/${issueIdentifier}"]`; + + const setMode = async (mode: "standard" | "planning") => { + const patchRes = await page.request.patch(`${baseOrigin}/api/issues/${issue.id}`, { + data: { workMode: mode }, + }); + expect(patchRes.ok()).toBe(true); + await expect + .poll(async () => { + const currentRes = await page.request.get(`${baseOrigin}/api/issues/${issue.id}`); + expect(currentRes.ok()).toBe(true); + const current = await currentRes.json(); + return current.workMode; + }, { timeout: 10_000 }) + .toBe(mode); + }; + + await setMode("planning"); + + await page.goto(issuePath); + await expect(page.getByText("Planning").first()).toBeVisible(); + await expect(page.getByTestId("issue-chat-composer")).toHaveAttribute("data-pending-work-mode", "planning"); + const desktopPlanningToggle = page.getByTestId("issue-chat-composer-work-mode-toggle"); + await expect(desktopPlanningToggle).toBeVisible(); + await expect(desktopPlanningToggle).toHaveAttribute("data-pending-work-mode", "planning"); + await expect(desktopPlanningToggle).toHaveAttribute("aria-pressed", "true"); + + await page.screenshot({ + path: `${screenshotDir}/desktop-planning-detail-${timestamp}.png`, + fullPage: true, + }); + + await page.goto(`/${companyPrefix}/issues`); + await expect(page.locator(issueLinkSelector)).toBeVisible(); + await expect(page.locator(issueLinkSelector)).toContainText("Planning"); + await page.screenshot({ + path: `${screenshotDir}/desktop-planning-row-${timestamp}.png`, + fullPage: true, + }); + + await page.goto(issuePath); + await page.getByTestId("issue-chat-composer-work-mode-toggle").click(); + await expect(page.getByTestId("issue-chat-composer")).toHaveAttribute("data-pending-work-mode", "standard"); + await expect(page.getByTestId("issue-chat-composer-work-mode-toggle")).toBeHidden(); + await page.screenshot({ + path: `${screenshotDir}/desktop-standard-toggle-${timestamp}.png`, + fullPage: true, + }); + + await setMode("planning"); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(issuePath); + await expect(page.getByText("Planning").first()).toBeVisible(); + const mobilePlanningToggle = page.getByTestId("issue-chat-composer-work-mode-toggle"); + await expect(mobilePlanningToggle).toBeVisible(); + await expect(mobilePlanningToggle).toHaveAttribute("data-pending-work-mode", "planning"); + await expect(mobilePlanningToggle).toHaveAttribute("aria-pressed", "true"); + await page.screenshot({ + path: `${screenshotDir}/mobile-planning-detail-${timestamp}.png`, + fullPage: true, + }); + + await page.goto(`/${companyPrefix}/issues`); + await expect(page.locator(issueLinkSelector)).toBeVisible(); + await expect(page.locator(issueLinkSelector)).toContainText("Planning"); + await page.screenshot({ + path: `${screenshotDir}/mobile-planning-row-${timestamp}.png`, + fullPage: true, + }); +}); diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 49eed112..3918b39b 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -346,6 +346,104 @@ describe("IssueChatThread", () => { }); }); + it("renders the composer in planning mode when the issue is in planning mode", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + onAdd={async () => {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const composer = container.querySelector('[data-testid="issue-chat-composer"]'); + expect(composer).not.toBeNull(); + expect(composer?.getAttribute("data-pending-work-mode")).toBe("planning"); + expect(composer?.className).toContain("amber"); + + const toggle = container.querySelector( + '[data-testid="issue-chat-composer-work-mode-toggle"]', + ); + expect(toggle).not.toBeNull(); + expect(toggle?.getAttribute("data-pending-work-mode")).toBe("planning"); + expect(toggle?.textContent).toContain("Planning"); + + act(() => { + root.unmount(); + }); + }); + + it("hides the planning chip on a standard issue and exposes the toggle through the menu", () => { + const root = createRoot(container); + const onWorkModeChange = vi.fn(); + + act(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + expect( + container.querySelector('[data-testid="issue-chat-composer-work-mode-toggle"]'), + ).toBeNull(); + const composer = container.querySelector('[data-testid="issue-chat-composer"]'); + expect(composer?.getAttribute("data-pending-work-mode")).toBe("standard"); + expect(composer?.className).not.toContain("amber"); + + const menuTrigger = container.querySelector( + '[data-testid="issue-chat-composer-work-mode-menu"]', + ) as HTMLButtonElement | null; + expect(menuTrigger).not.toBeNull(); + act(() => { + menuTrigger?.click(); + }); + + const menuItem = document.querySelector( + '[data-testid="issue-chat-composer-work-mode-menu-toggle"]', + ) as HTMLButtonElement | null; + expect(menuItem).not.toBeNull(); + expect(menuItem?.textContent).toContain("Switch to planning"); + + act(() => { + menuItem?.click(); + }); + + expect(onWorkModeChange).not.toHaveBeenCalled(); + expect(composer?.getAttribute("data-pending-work-mode")).toBe("planning"); + expect(composer?.className).toContain("amber"); + + const visibleChip = container.querySelector( + '[data-testid="issue-chat-composer-work-mode-toggle"]', + ); + expect(visibleChip).not.toBeNull(); + expect(visibleChip?.textContent).toContain("Planning"); + + act(() => { + root.unmount(); + }); + }); + it("virtualizes long merged threads so only a windowed slice mounts", () => { const root = createRoot(container); const totalMergedRows = diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 843f8e59..792b129f 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -37,6 +37,7 @@ import type { IssueBlockerAttention, IssueRelationIssueSummary, SuccessfulRunHandoffState, + IssueWorkMode, } from "@paperclipai/shared"; import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; @@ -117,7 +118,7 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Textarea } from "@/components/ui/textarea"; -import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, PauseCircle, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react"; +import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, ClipboardList, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, PauseCircle, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react"; import { IssueBlockedNotice } from "./IssueBlockedNotice"; interface IssueChatMessageContext { @@ -261,6 +262,8 @@ interface IssueChatComposerProps { composerDisabledReason?: string | null; composerHint?: string | null; issueStatus?: string; + issueWorkMode?: IssueWorkMode; + onWorkModeChange?: (workMode: IssueWorkMode) => Promise | void; } interface IssueChatThreadProps { @@ -304,6 +307,7 @@ interface IssueChatThreadProps { mentions?: MentionOption[]; composerDisabledReason?: string | null; composerHint?: string | null; + onWorkModeChange?: (workMode: IssueWorkMode) => Promise | void; showComposer?: boolean; showJumpToLatest?: boolean; emptyMessage?: string; @@ -333,6 +337,7 @@ interface IssueChatThreadProps { interaction: AskUserQuestionsInteraction, ) => Promise | void; composerRef?: Ref; + issueWorkMode?: IssueWorkMode; /** * Hook for the parent to refetch comments when the user explicitly asks * to jump to the latest comment. Used to make sure the absolute newest @@ -2816,6 +2821,8 @@ const IssueChatComposer = forwardRef(resolvedIssueWorkMode); + const [workModeMenuOpen, setWorkModeMenuOpen] = useState(false); + const canToggleWorkMode = typeof onWorkModeChange === "function"; const attachInputRef = useRef(null); const editorRef = useRef(null); const composerContainerRef = useRef(null); @@ -2878,6 +2889,10 @@ const IssueChatComposer = forwardRef { + setPendingWorkMode(resolvedIssueWorkMode); + }, [resolvedIssueWorkMode]); + useImperativeHandle(forwardedRef, () => ({ focus: focusComposer, restoreDraft: (submittedBody: string) => { @@ -2920,10 +2935,14 @@ const IssueChatComposer = forwardRef - {(onImageUpload || onAttachImage) ? ( -
- - + + ) : null} + {canToggleWorkMode ? ( + + + + + + + + + ) : null} + {canToggleWorkMode && isPlanning ? ( + -
- ) : null} + + Planning + + ) : null} + {enableReassign && reassignOptions.length > 0 ? ( - )) - )} + )) + )} {showComposer ? (
) : null} diff --git a/ui/src/components/IssueDocumentsSection.test.tsx b/ui/src/components/IssueDocumentsSection.test.tsx index 5b6c4bb1..31a6e76a 100644 --- a/ui/src/components/IssueDocumentsSection.test.tsx +++ b/ui/src/components/IssueDocumentsSection.test.tsx @@ -215,6 +215,7 @@ function createIssue(): Issue { title: "Plan rendering", description: null, status: "in_progress", + workMode: "standard", priority: "medium", assigneeAgentId: null, assigneeUserId: null, diff --git a/ui/src/components/IssueMonitorActivityCard.test.tsx b/ui/src/components/IssueMonitorActivityCard.test.tsx index d00aa464..3731a86e 100644 --- a/ui/src/components/IssueMonitorActivityCard.test.tsx +++ b/ui/src/components/IssueMonitorActivityCard.test.tsx @@ -81,6 +81,7 @@ function createIssue(overrides: Partial = {}): Issue { createdAt: new Date("2026-04-11T10:00:00.000Z"), updatedAt: new Date("2026-04-11T10:00:00.000Z"), ...overrides, + workMode: overrides.workMode ?? "standard", }; } diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index fa54fc39..283f93ce 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -162,6 +162,7 @@ function createIssue(overrides: Partial = {}): Issue { createdAt: new Date("2026-04-06T12:00:00.000Z"), updatedAt: new Date("2026-04-06T12:05:00.000Z"), ...overrides, + workMode: overrides.workMode ?? "standard", }; } diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 944712e6..5919b123 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -68,6 +68,7 @@ function createIssue(overrides: Partial = {}): Issue { lastExternalCommentAt: null, isUnreadForMe: false, ...overrides, + workMode: overrides.workMode ?? "standard", }; } @@ -227,6 +228,22 @@ describe("IssueRow", () => { }); }); + it("renders planning mode marker for planning work mode issues", () => { + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null; + expect(link).not.toBeNull(); + expect(link?.textContent).toContain("Planning"); + + act(() => { + root.unmount(); + }); + }); + it("renders without error when titleSuffix is omitted", () => { const root = createRoot(container); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 664990b1..576a502f 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -83,6 +83,14 @@ export function IssueRow({ {checklistStepNumber}. ) : null; + const planningModeIndicator = issue.workMode === "planning" ? ( + + Planning + + ) : null; return ( {mobileLeading ?? } {productivityReviewIndicator} + {planningModeIndicator} @@ -128,6 +137,7 @@ export function IssueRow({ {identifier} + {planningModeIndicator} )} {mobileMeta ? ( diff --git a/ui/src/components/IssueRunLedger.test.tsx b/ui/src/components/IssueRunLedger.test.tsx index 9913971a..a2dc5b48 100644 --- a/ui/src/components/IssueRunLedger.test.tsx +++ b/ui/src/components/IssueRunLedger.test.tsx @@ -114,6 +114,7 @@ function createIssue(overrides: Partial = {}): Issue { createdAt: new Date("2026-04-18T19:00:00.000Z"), updatedAt: new Date("2026-04-18T19:00:00.000Z"), ...overrides, + workMode: overrides.workMode ?? "standard", }; } diff --git a/ui/src/components/IssueWorkspaceCard.test.tsx b/ui/src/components/IssueWorkspaceCard.test.tsx index 6744c9ef..148362e8 100644 --- a/ui/src/components/IssueWorkspaceCard.test.tsx +++ b/ui/src/components/IssueWorkspaceCard.test.tsx @@ -110,6 +110,7 @@ function createIssue(overrides: Partial = {}): Issue { labelIds: [], currentExecutionWorkspace: null, ...overrides, + workMode: overrides.workMode ?? "standard", }; } diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index aa13ac30..dce7a5df 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -179,6 +179,7 @@ function createIssue(overrides: Partial = {}): Issue { lastActivityAt: null, isUnreadForMe: false, ...overrides, + workMode: overrides.workMode ?? "standard", }; } diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index 9efadd21..569d1e8f 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -405,6 +405,42 @@ describe("NewIssueDialog", () => { goalId: "goal-1", projectId: "project-1", executionWorkspaceId: "workspace-1", + workMode: "standard", + }), + ); + + act(() => root.unmount()); + }); + + it("restores the planning mode from dialog defaults", async () => { + dialogState.newIssueDefaults = { + title: "Planned from defaults", + workMode: "planning", + }; + + const { root } = renderDialog(container); + await flush(); + + const planningButton = container.querySelector('[data-issue-work-mode="planning"]'); + expect(planningButton?.className).toContain("bg-accent"); + + const submitButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Create Issue")); + expect(submitButton).not.toBeUndefined(); + await vi.waitFor(() => { + expect(submitButton?.hasAttribute("disabled")).toBe(false); + }); + + await act(async () => { + submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(mockIssuesApi.create).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + title: "Planned from defaults", + workMode: "planning", }), ); @@ -545,6 +581,45 @@ describe("NewIssueDialog", () => { expect.objectContaining({ title: "Typed issue", description: "Typed description", + workMode: "standard", + }), + ); + + act(() => root.unmount()); + }); + + it("submits planning work mode when planning is selected", async () => { + const { root } = renderDialog(container); + await flush(); + + const titleInput = container.querySelector('textarea[placeholder="Issue title"]') as HTMLTextAreaElement | null; + expect(titleInput).not.toBeNull(); + await typeTextareaValue(titleInput!, "Plan this first"); + + const planningButton = container.querySelector('[data-issue-work-mode="planning"]'); + expect(planningButton).not.toBeNull(); + await act(async () => { + planningButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + const submitButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Create Issue")); + expect(submitButton).not.toBeUndefined(); + await vi.waitFor(() => { + expect(submitButton?.hasAttribute("disabled")).toBe(false); + }); + + await act(async () => { + submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(mockIssuesApi.create).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + title: "Plan this first", + workMode: "planning", }), ); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 3ff174b4..8cf55baf 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1,5 +1,6 @@ import { memo, useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent, type RefObject } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import type { IssueWorkMode } from "@paperclipai/shared"; import { pickTextColorForSolidBg } from "@/lib/color-contrast"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; @@ -43,6 +44,8 @@ import { ChevronRight, ChevronDown, CircleDot, + ClipboardList, + Hammer, Minus, ArrowUp, ArrowDown, @@ -86,6 +89,7 @@ interface IssueDraft { executionWorkspaceMode?: string; selectedExecutionWorkspaceId?: string; useIsolatedExecutionWorkspace?: boolean; + workMode?: IssueWorkMode; } type StagedIssueFile = { @@ -130,6 +134,19 @@ const ISSUE_THINKING_EFFORT_OPTIONS = { ], } as const; +function isIssueWorkMode(value: unknown): value is IssueWorkMode { + return value === "standard" || value === "planning"; +} + +const ISSUE_WORK_MODE_OPTIONS: ReadonlyArray<{ + value: IssueWorkMode; + label: string; + icon: typeof Hammer; +}> = [ + { value: "standard", label: "Standard", icon: Hammer }, + { value: "planning", label: "Planning", icon: ClipboardList }, +]; + function loadDraft(): IssueDraft | null { try { const raw = localStorage.getItem(DRAFT_KEY); @@ -400,6 +417,7 @@ export function NewIssueDialog() { const [assigneeChrome, setAssigneeChrome] = useState(false); const [executionWorkspaceMode, setExecutionWorkspaceMode] = useState("shared_workspace"); const [selectedExecutionWorkspaceId, setSelectedExecutionWorkspaceId] = useState(""); + const [workMode, setWorkMode] = useState("standard"); const [expanded, setExpanded] = useState(false); const [dialogCompanyId, setDialogCompanyId] = useState(null); const [stagedFiles, setStagedFiles] = useState([]); @@ -419,6 +437,7 @@ export function NewIssueDialog() { // Popover states const [statusOpen, setStatusOpen] = useState(false); const [priorityOpen, setPriorityOpen] = useState(false); + const [workModeOpen, setWorkModeOpen] = useState(false); const [moreOpen, setMoreOpen] = useState(false); const [companyOpen, setCompanyOpen] = useState(false); const descriptionEditorRef = useRef(null); @@ -626,6 +645,7 @@ export function NewIssueDialog() { assigneeChrome, executionWorkspaceMode, selectedExecutionWorkspaceId, + workMode, }); }, [ newIssueOpen, @@ -642,6 +662,7 @@ export function NewIssueDialog() { assigneeChrome, executionWorkspaceMode, selectedExecutionWorkspaceId, + workMode, ]); const handleTitleChange = useCallback((nextTitle: string) => { @@ -678,6 +699,7 @@ export function NewIssueDialog() { assigneeChrome, executionWorkspaceMode, selectedExecutionWorkspaceId, + workMode, newIssueOpen, queueDraftSave, ]); @@ -696,6 +718,7 @@ export function NewIssueDialog() { const draft = loadDraft(); if (newIssueDefaults.parentId) { + const nextWorkMode = isIssueWorkMode(newIssueDefaults.workMode) ? newIssueDefaults.workMode : "standard"; const defaultProjectId = newIssueDefaults.projectId ?? ""; const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId); const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined; @@ -713,11 +736,13 @@ export function NewIssueDialog() { setAssigneeThinkingEffort(""); setAssigneeChrome(false); setExecutionWorkspaceMode(defaultExecutionWorkspaceMode); + setWorkMode(nextWorkMode); setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? ""); executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || defaultProject ? defaultProjectId || null : null; } else if (newIssueDefaults.title) { + const nextWorkMode = isIssueWorkMode(newIssueDefaults.workMode) ? newIssueDefaults.workMode : "standard"; setIssueText(newIssueDefaults.title, newIssueDefaults.description ?? ""); setStatus(newIssueDefaults.status ?? "todo"); setPriority(newIssueDefaults.priority ?? ""); @@ -735,11 +760,13 @@ export function NewIssueDialog() { setAssigneeThinkingEffort(""); setAssigneeChrome(false); setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject)); + setWorkMode(nextWorkMode); setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? ""); executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || newIssueDefaults.executionWorkspaceId || defaultProject ? defaultProjectId || null : null; } else if (draft && draft.title.trim()) { + const nextWorkMode = isIssueWorkMode(draft.workMode) ? draft.workMode : "standard"; const restoredProjectId = newIssueDefaults.projectId ?? draft.projectId; const restoredProject = orderedProjects.find((project) => project.id === restoredProjectId); const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined; @@ -775,6 +802,7 @@ export function NewIssueDialog() { ?? (draft.useIsolatedExecutionWorkspace ? "isolated_workspace" : defaultExecutionWorkspaceModeForProject(restoredProject)) ), ); + setWorkMode(nextWorkMode); setSelectedExecutionWorkspaceId( hasExplicitExecutionWorkspaceId ? (newIssueDefaults.executionWorkspaceId ?? "") @@ -784,6 +812,7 @@ export function NewIssueDialog() { ? restoredProjectId || null : null; } else { + setWorkMode("standard"); const defaultProjectId = newIssueDefaults.projectId ?? ""; const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId); const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined; @@ -863,6 +892,7 @@ export function NewIssueDialog() { setAssigneeChrome(false); setExecutionWorkspaceMode("shared_workspace"); setSelectedExecutionWorkspaceId(""); + setWorkMode("standard"); setExpanded(false); setDialogCompanyId(null); setStagedFiles([]); @@ -889,6 +919,7 @@ export function NewIssueDialog() { setAssigneeChrome(false); setExecutionWorkspaceMode("shared_workspace"); setSelectedExecutionWorkspaceId(""); + setWorkMode("standard"); } function discardDraft() { @@ -939,6 +970,7 @@ export function NewIssueDialog() { description: currentDescription || undefined, status, priority: priority || "medium", + workMode, ...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}), ...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}), ...(newIssueDefaults.parentId ? { parentId: newIssueDefaults.parentId } : {}), @@ -1146,6 +1178,8 @@ export function NewIssueDialog() { }, [assigneeAdapterModels], ); + const currentWorkMode = ISSUE_WORK_MODE_OPTIONS[workMode === "planning" ? 1 : 0]!; + const CurrentWorkModeIcon = currentWorkMode.icon; return ( + {/* Work mode chip */} + + + + + + {ISSUE_WORK_MODE_OPTIONS.map((option) => { + const Icon = option.icon; + return ( + + ); + })} + + + {/* More (dates) */} diff --git a/ui/src/context/DialogContext.tsx b/ui/src/context/DialogContext.tsx index 697a6890..90f1b2ed 100644 --- a/ui/src/context/DialogContext.tsx +++ b/ui/src/context/DialogContext.tsx @@ -1,7 +1,9 @@ import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; +import type { IssueWorkMode } from "@paperclipai/shared"; interface NewIssueDefaults { status?: string; + workMode?: IssueWorkMode; priority?: string; projectId?: string; projectWorkspaceId?: string; diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index b82ac6c1..b03cf119 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -184,6 +184,7 @@ function makeIssue(id: string, isUnreadForMe: boolean): Issue { title: `Issue ${id}`, description: null, status: "todo", + workMode: "standard", priority: "medium", assigneeAgentId: null, assigneeUserId: null, diff --git a/ui/src/lib/issue-filters.test.ts b/ui/src/lib/issue-filters.test.ts index 504182b7..bb0afde7 100644 --- a/ui/src/lib/issue-filters.test.ts +++ b/ui/src/lib/issue-filters.test.ts @@ -47,6 +47,7 @@ function makeIssue(overrides: Partial = {}): Issue { createdAt: new Date("2026-04-15T00:00:00.000Z"), updatedAt: new Date("2026-04-15T00:00:00.000Z"), ...overrides, + workMode: overrides.workMode ?? "standard", }; } diff --git a/ui/src/lib/issue-tree.test.ts b/ui/src/lib/issue-tree.test.ts index 955f3a0d..1fb43eed 100644 --- a/ui/src/lib/issue-tree.test.ts +++ b/ui/src/lib/issue-tree.test.ts @@ -14,6 +14,7 @@ function makeIssue(id: string, parentId: string | null = null): Issue { title: `Issue ${id}`, description: null, status: "todo", + workMode: "standard", priority: "medium", assigneeAgentId: null, assigneeUserId: null, diff --git a/ui/src/lib/issueDetailBreadcrumb.test.ts b/ui/src/lib/issueDetailBreadcrumb.test.ts index 5c3e1be0..790fc4cd 100644 --- a/ui/src/lib/issueDetailBreadcrumb.test.ts +++ b/ui/src/lib/issueDetailBreadcrumb.test.ts @@ -114,6 +114,7 @@ describe("issueDetailBreadcrumb", () => { createdAt: new Date(), updatedAt: new Date(), ...overrides, + workMode: overrides.workMode ?? "standard", }; } diff --git a/ui/src/lib/issueDetailCache.test.ts b/ui/src/lib/issueDetailCache.test.ts index dd55ba72..591f0a9b 100644 --- a/ui/src/lib/issueDetailCache.test.ts +++ b/ui/src/lib/issueDetailCache.test.ts @@ -56,6 +56,7 @@ function createIssue(overrides: Partial = {}): Issue { lastExternalCommentAt: null, isUnreadForMe: false, ...overrides, + workMode: overrides.workMode ?? "standard", }; } diff --git a/ui/src/lib/issueDetailQuery.test.tsx b/ui/src/lib/issueDetailQuery.test.tsx index 83be05dd..541350ee 100644 --- a/ui/src/lib/issueDetailQuery.test.tsx +++ b/ui/src/lib/issueDetailQuery.test.tsx @@ -54,6 +54,7 @@ function makeIssue(overrides: Partial = {}): Issue { createdAt: now, updatedAt: now, ...overrides, + workMode: overrides.workMode ?? "standard", }; } diff --git a/ui/src/lib/optimistic-issue-comments.test.ts b/ui/src/lib/optimistic-issue-comments.test.ts index ac7d6dac..b8e8d300 100644 --- a/ui/src/lib/optimistic-issue-comments.test.ts +++ b/ui/src/lib/optimistic-issue-comments.test.ts @@ -446,6 +446,7 @@ describe("optimistic issue comments", () => { title: "Fix comment flow", description: null, status: "done", + workMode: "standard", priority: "medium", assigneeAgentId: "agent-1", assigneeUserId: null, @@ -515,6 +516,7 @@ describe("optimistic issue comments", () => { title: "Fix property pane", description: null, status: "todo", + workMode: "standard", priority: "medium", assigneeAgentId: "agent-1", assigneeUserId: null, @@ -687,6 +689,7 @@ describe("optimistic issue comments", () => { title: "Fix property pane", description: null, status: "todo", + workMode: "standard", priority: "medium", assigneeAgentId: "agent-1", assigneeUserId: null, @@ -728,6 +731,7 @@ describe("optimistic issue comments", () => { title: "Leave me alone", description: null, status: "todo", + workMode: "standard", priority: "medium", assigneeAgentId: "agent-2", assigneeUserId: null, diff --git a/ui/src/lib/subIssueDefaults.test.ts b/ui/src/lib/subIssueDefaults.test.ts index 3c9d9b1b..6d45f5d8 100644 --- a/ui/src/lib/subIssueDefaults.test.ts +++ b/ui/src/lib/subIssueDefaults.test.ts @@ -69,6 +69,7 @@ function makeIssue(overrides: Partial = {}): Issue { createdAt: new Date("2026-04-07T00:00:00.000Z"), updatedAt: new Date("2026-04-07T00:00:00.000Z"), ...overrides, + workMode: overrides.workMode ?? "standard", }; } diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index 2d12b81f..dcac90ee 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -66,6 +66,7 @@ function createIssue(overrides: Partial = {}): Issue { lastActivityAt: new Date("2026-03-11T00:00:00.000Z"), isUnreadForMe: false, ...overrides, + workMode: overrides.workMode ?? "standard", }; } diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index e643f145..331d71ca 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -192,6 +192,8 @@ vi.mock("../components/InlineEditor", () => ({ vi.mock("../components/IssueChatThread", () => ({ IssueChatThread: (props: { + onWorkModeChange?: (workMode: string) => void; + issueWorkMode?: string; onStopRun?: (runId: string) => Promise; stopRunLabel?: string; stoppingRunLabel?: string; @@ -1099,6 +1101,7 @@ describe("IssueDetail", () => { expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({ stopRunLabel: "Pause work", stoppingRunLabel: "Pausing...", + issueWorkMode: "standard", }); const chatPauseButton = Array.from(container.querySelectorAll("button")) @@ -1129,6 +1132,67 @@ describe("IssueDetail", () => { expect(pauseMenuButton).toBeTruthy(); }); + it("passes planning work mode to the issue chat thread", async () => { + mockIssuesApi.get.mockResolvedValue(createIssue({ workMode: "planning" })); + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({ + issueWorkMode: "planning", + }); + expect(container.textContent).toContain("Planning"); + }); + + it("forwards composer work mode changes to the issues API", async () => { + const issue = createIssue(); + mockIssuesApi.get.mockResolvedValue(issue); + mockIssuesApi.listAttachments.mockResolvedValue([ + { + id: "attachment-1", + issueId: issue.id, + issueCommentId: null, + originalFilename: "planning-notes.txt", + contentPath: "/attachments/planning-notes.txt", + contentType: "text/plain", + byteSize: 4096, + uploadedByUserId: null, + uploadedAt: new Date("2026-04-21T00:02:00.000Z"), + }, + ]); + localStorage.setItem("paperclip:issue-comment-draft:issue-1", "Draft follow-up message"); + mockIssuesApi.update.mockResolvedValue(createIssue({ workMode: "planning" })); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + const lastChatThreadProps = mockIssueChatThreadRender.mock.calls.at(-1)?.[0]; + expect(lastChatThreadProps?.issueWorkMode).toBe("standard"); + expect(typeof lastChatThreadProps?.onWorkModeChange).toBe("function"); + + await act(async () => { + lastChatThreadProps?.onWorkModeChange?.("planning"); + }); + await flushReact(); + + expect(mockIssuesApi.update).toHaveBeenCalledWith(issue.identifier, { workMode: "planning" }); + expect(localStorage.getItem("paperclip:issue-comment-draft:issue-1")).toBe("Draft follow-up message"); + expect(container.textContent).toContain("planning-notes.txt"); + localStorage.removeItem("paperclip:issue-comment-draft:issue-1"); + }); + it("renders Paused by board distinctly and defaults leaf resume to wake the assignee", async () => { const activeHold = createPauseHold(); const releasedHold = createPauseHold({ diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 84681c13..e4223649 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -145,6 +145,7 @@ import { type Issue, type IssueAttachment, type IssueComment, + type IssueWorkMode, type IssueThreadInteraction, type RequestConfirmationInteraction, type SuggestTasksInteraction, @@ -186,7 +187,6 @@ const LEAF_WORK_CONTROL_MODE_HELP_TEXT: Partial void; onRefreshLatestComments: () => Promise | void; + onWorkModeChange?: (workMode: IssueWorkMode) => Promise | void; composerRef: Ref; feedbackVotes?: FeedbackVote[]; feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt"; @@ -638,6 +640,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ issueId, companyId, projectId, + issueWorkMode, issueStatus, executionRunId, blockedBy, @@ -650,6 +653,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ commentsLoadingOlder, onLoadOlderComments, onRefreshLatestComments, + onWorkModeChange, composerRef, feedbackVotes, feedbackDataSharingPreference, @@ -878,6 +882,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ onSubmitInteractionAnswers(interaction, answers) } onCancelInteraction={onCancelInteraction} + issueWorkMode={issueWorkMode} + onWorkModeChange={onWorkModeChange} onCancelRun={runningIssueRun && onPauseWorkRun ? async () => { await onPauseWorkRun(runningIssueRun.id); @@ -3190,6 +3196,15 @@ export function IssueDetail() { ) : null} + {issue.workMode === "planning" ? ( + + Planning + + ) : null} + {issue.projectId ? ( pauseIssueWorkRun.mutateAsync({ runId, scope: treeControlScope }).then(() => undefined) : undefined} + onWorkModeChange={(nextMode) => { + const currentMode: IssueWorkMode = issue.workMode ?? "standard"; + if (currentMode === nextMode) return; + return updateIssue.mutateAsync({ workMode: nextMode }).then(() => undefined); + }} onCancelQueued={handleCancelQueuedComment} interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null} pausingWorkRunId={pauseIssueWorkRun.isPending ? pauseIssueWorkRun.variables?.runId ?? null : null} diff --git a/ui/src/pages/Routines.test.tsx b/ui/src/pages/Routines.test.tsx index c89b5f68..ee72a878 100644 --- a/ui/src/pages/Routines.test.tsx +++ b/ui/src/pages/Routines.test.tsx @@ -308,6 +308,7 @@ function createIssue(overrides: Partial = {}): Issue { lastActivityAt: new Date("2026-04-01T00:00:00.000Z"), isUnreadForMe: false, ...overrides, + workMode: overrides.workMode ?? "standard", }; } diff --git a/ui/storybook/fixtures/paperclipData.ts b/ui/storybook/fixtures/paperclipData.ts index cd8f701a..dc43be49 100644 --- a/ui/storybook/fixtures/paperclipData.ts +++ b/ui/storybook/fixtures/paperclipData.ts @@ -742,6 +742,7 @@ export function createIssue(overrides: Partial = {}): Issue { createdAt: recent(90), updatedAt: recent(3), ...overrides, + workMode: overrides.workMode ?? "standard", }; } diff --git a/ui/storybook/stories/chat-comments.stories.tsx b/ui/storybook/stories/chat-comments.stories.tsx index 3f7130f0..cb5431da 100644 --- a/ui/storybook/stories/chat-comments.stories.tsx +++ b/ui/storybook/stories/chat-comments.stories.tsx @@ -675,6 +675,26 @@ function IssueChatMatrix() { composerDisabledReason="This issue is in review. Request changes or approve it from the review controls." /> + + undefined} + onAdd={async () => {}} + enableLiveTranscriptPolling={false} + emptyMessage="Planning mode reply box example." + /> +