Add planning mode for issue work (#5353)

## Thinking Path

> - Paperclip is a control plane for autonomous AI companies.
> - Issues are the core unit of work, and issue comments are how board
users and agents coordinate execution.
> - Some issue conversations need to produce plans and approvals instead
of immediate implementation work.
> - The existing issue contract did not distinguish standard execution
comments from planning-oriented issue work.
> - This pull request adds an issue work-mode contract and board UI
affordances for standard vs planning mode.
> - The benefit is that planning-mode issues can be created, displayed,
discussed, and carried through agent heartbeat context without losing
the normal issue workflow.

## What Changed

- Added `standard` / `planning` issue work-mode contracts across DB,
shared validators/types, server issue flows, plugin protocol, and
adapter heartbeat payloads.
- Added an idempotent `0081_optimal_dormammu` migration for
`issues.work_mode`, ordered after current `public-gh/master` migrations.
- Updated heartbeat/context summaries and issue-thread interaction
behavior so planning work mode is preserved when creating suggested
follow-up issues.
- Added UI support for planning-mode issue creation, issue rows, detail
composer styling, and composer work-mode toggles.
- Added focused server/shared/UI tests plus a Playwright visual
verification spec for planning-mode surfaces.
- Rebased the branch onto current `public-gh/master` and added durable
planning-mode screenshots under `doc/assets/pap-3368/`.

## Verification

- `pnpm --filter @paperclipai/db run check:migrations`
- `pnpm exec vitest run --project @paperclipai/shared
packages/shared/src/validators/issue.test.ts`
- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/heartbeat-context-summary.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts
server/src/__tests__/issues-goal-context-routes.test.ts --pool=forks
--poolOptions.forks.isolate=true`
- `pnpm exec vitest run --project @paperclipai/ui
ui/src/components/IssueChatThread.test.tsx
ui/src/components/NewIssueDialog.test.tsx
ui/src/components/IssueRow.test.tsx ui/src/pages/IssueDetail.test.tsx`
- `pnpm exec vitest run --project @paperclipai/adapter-utils
packages/adapter-utils/src/server-utils.test.ts`
- `PAPERCLIP_E2E_SKIP_LLM=true npx playwright test --config
tests/e2e/playwright.config.ts
tests/e2e/planning-mode-visual-verification.spec.ts`

## Screenshots

Desktop planning detail:

![Desktop planning
detail](https://raw.githubusercontent.com/paperclipai/paperclip/PAP-3368-plan-a-planning-mode-for-issues/doc/assets/pap-3368/desktop-planning-detail.png)

Desktop planning row:

![Desktop planning
row](https://raw.githubusercontent.com/paperclipai/paperclip/PAP-3368-plan-a-planning-mode-for-issues/doc/assets/pap-3368/desktop-planning-row.png)

Desktop staged standard toggle:

![Desktop staged standard
toggle](https://raw.githubusercontent.com/paperclipai/paperclip/PAP-3368-plan-a-planning-mode-for-issues/doc/assets/pap-3368/desktop-standard-toggle.png)

Mobile planning detail:

![Mobile planning
detail](https://raw.githubusercontent.com/paperclipai/paperclip/PAP-3368-plan-a-planning-mode-for-issues/doc/assets/pap-3368/mobile-planning-detail.png)

Mobile planning row:

![Mobile planning
row](https://raw.githubusercontent.com/paperclipai/paperclip/PAP-3368-plan-a-planning-mode-for-issues/doc/assets/pap-3368/mobile-planning-row.png)

## Risks

- Medium migration risk: this adds a non-null issue column. The
migration uses `ADD COLUMN IF NOT EXISTS` so installations that applied
an older branch-local migration number can still apply the final
numbered migration safely.
- Medium contract risk: issue payloads, plugin payloads, and adapter
heartbeat payloads now include work mode; compatibility is handled by
defaulting missing values to `standard`.
- UI risk is moderate because composer controls changed; focused
component tests and visual e2e coverage exercise standard vs planning
display and toggle behavior.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent in a local Paperclip worktree, with
shell/tool use. Exact context-window size is not exposed in this
runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-05-06 07:01:28 -05:00
committed by GitHub
parent 320fd5d23b
commit a1b30c9f35
65 changed files with 1539 additions and 214 deletions
+76
View File
@@ -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<string>("shared_workspace");
const [selectedExecutionWorkspaceId, setSelectedExecutionWorkspaceId] = useState("");
const [workMode, setWorkMode] = useState<IssueWorkMode>("standard");
const [expanded, setExpanded] = useState(false);
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
const [stagedFiles, setStagedFiles] = useState<StagedIssueFile[]>([]);
@@ -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<MarkdownEditorRef>(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 (
<Dialog
@@ -1868,6 +1902,48 @@ export function NewIssueDialog() {
Upload
</button>
{/* Work mode chip */}
<Popover open={workModeOpen} onOpenChange={setWorkModeOpen}>
<PopoverTrigger asChild>
<button
type="button"
data-issue-work-mode-chip={workMode}
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors",
workMode === "planning"
? "border-amber-500/60 bg-amber-500/15 text-amber-800 hover:bg-amber-500/25 dark:border-amber-500/50 dark:bg-amber-500/15 dark:text-amber-200 dark:hover:bg-amber-500/25"
: "border-border text-muted-foreground hover:bg-accent/50",
)}
>
<CurrentWorkModeIcon className="h-3 w-3" />
{currentWorkMode.label}
</button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start">
{ISSUE_WORK_MODE_OPTIONS.map((option) => {
const Icon = option.icon;
return (
<button
key={option.value}
data-issue-work-mode={option.value}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
option.value === workMode && "bg-accent",
option.value === "planning" && "text-amber-700 dark:text-amber-300",
)}
onClick={() => {
setWorkMode(option.value);
setWorkModeOpen(false);
}}
>
<Icon className="h-3 w-3" />
{option.label}
</button>
);
})}
</PopoverContent>
</Popover>
{/* More (dates) */}
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
<PopoverTrigger asChild>