mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +09:00
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 row:  Desktop staged standard toggle:  Mobile planning detail:  Mobile planning row:  ## 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:
parent
320fd5d23b
commit
a1b30c9f35
65 changed files with 1539 additions and 214 deletions
|
|
@ -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> | void;
|
||||
}
|
||||
|
||||
interface IssueChatThreadProps {
|
||||
|
|
@ -304,6 +307,7 @@ interface IssueChatThreadProps {
|
|||
mentions?: MentionOption[];
|
||||
composerDisabledReason?: string | null;
|
||||
composerHint?: string | null;
|
||||
onWorkModeChange?: (workMode: IssueWorkMode) => Promise<void> | void;
|
||||
showComposer?: boolean;
|
||||
showJumpToLatest?: boolean;
|
||||
emptyMessage?: string;
|
||||
|
|
@ -333,6 +337,7 @@ interface IssueChatThreadProps {
|
|||
interaction: AskUserQuestionsInteraction,
|
||||
) => Promise<void> | void;
|
||||
composerRef?: Ref<IssueChatComposerHandle>;
|
||||
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<IssueChatComposerHandle, IssueChatComposerP
|
|||
composerDisabledReason = null,
|
||||
composerHint = null,
|
||||
issueStatus,
|
||||
issueWorkMode,
|
||||
onWorkModeChange,
|
||||
}, forwardedRef) {
|
||||
const api = useAui();
|
||||
const toastActions = useOptionalToastActions();
|
||||
|
|
@ -2828,6 +2835,10 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
|||
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
||||
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
|
||||
const [unassignedConfirmed, setUnassignedConfirmed] = useState(false);
|
||||
const resolvedIssueWorkMode: IssueWorkMode = issueWorkMode ?? "standard";
|
||||
const [pendingWorkMode, setPendingWorkMode] = useState<IssueWorkMode>(resolvedIssueWorkMode);
|
||||
const [workModeMenuOpen, setWorkModeMenuOpen] = useState(false);
|
||||
const canToggleWorkMode = typeof onWorkModeChange === "function";
|
||||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const editorRef = useRef<MarkdownEditorRef>(null);
|
||||
const composerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -2878,6 +2889,10 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
|||
setUnassignedConfirmed(false);
|
||||
}, [reassignTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
setPendingWorkMode(resolvedIssueWorkMode);
|
||||
}, [resolvedIssueWorkMode]);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
focus: focusComposer,
|
||||
restoreDraft: (submittedBody: string) => {
|
||||
|
|
@ -2920,10 +2935,14 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
|||
const submittedBody = trimmed;
|
||||
const viewportSnapshot = captureComposerViewportSnapshot(composerContainerRef.current);
|
||||
|
||||
const workModeChanged = pendingWorkMode !== resolvedIssueWorkMode;
|
||||
setSubmitting(true);
|
||||
setBody("");
|
||||
setUnassignedConfirmed(false);
|
||||
try {
|
||||
if (workModeChanged && onWorkModeChange) {
|
||||
await onWorkModeChange(pendingWorkMode);
|
||||
}
|
||||
const appendPromise = api.thread().append({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: submittedBody }],
|
||||
|
|
@ -3081,12 +3100,16 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
|||
);
|
||||
}
|
||||
|
||||
const isPlanning = pendingWorkMode === "planning";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={composerContainerRef}
|
||||
data-testid="issue-chat-composer"
|
||||
data-pending-work-mode={pendingWorkMode}
|
||||
className={cn(
|
||||
"relative rounded-md border border-border/70 bg-background/95 p-[15px] shadow-[0_-12px_28px_rgba(15,23,42,0.08)] backdrop-blur transition-[border-color,background-color,box-shadow] duration-150 supports-[backdrop-filter]:bg-background/85 dark:shadow-[0_-12px_28px_rgba(0,0,0,0.28)]",
|
||||
isPlanning && "border-amber-500/60 bg-amber-50/60 supports-[backdrop-filter]:bg-amber-50/40 dark:border-amber-500/50 dark:bg-amber-500/[0.07] dark:supports-[backdrop-filter]:bg-amber-500/[0.07]",
|
||||
isDragOver && "border-primary/45 bg-background shadow-[0_-12px_28px_rgba(15,23,42,0.08),0_0_0_1px_hsl(var(--primary)/0.16)]",
|
||||
)}
|
||||
onDragEnterCapture={handleFileDragEnter}
|
||||
|
|
@ -3178,25 +3201,77 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
|||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
{(onImageUpload || onAttachImage) ? (
|
||||
<div className="mr-auto flex items-center gap-3">
|
||||
<input
|
||||
ref={attachInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleAttachFile}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => attachInputRef.current?.click()}
|
||||
disabled={attaching}
|
||||
title="Attach file"
|
||||
<div className="mr-auto flex items-center gap-2">
|
||||
{(onImageUpload || onAttachImage) ? (
|
||||
<>
|
||||
<input
|
||||
ref={attachInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleAttachFile}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => attachInputRef.current?.click()}
|
||||
disabled={attaching}
|
||||
title="Attach file"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{canToggleWorkMode ? (
|
||||
<Popover open={workModeMenuOpen} onOpenChange={setWorkModeMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
data-testid="issue-chat-composer-work-mode-menu"
|
||||
title="More composer options"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1" align="start">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="issue-chat-composer-work-mode-menu-toggle"
|
||||
data-pending-work-mode={pendingWorkMode}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
|
||||
isPlanning ? "text-amber-700 dark:text-amber-300" : "text-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
setPendingWorkMode((prev) => (prev === "planning" ? "standard" : "planning"));
|
||||
setWorkModeMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{isPlanning ? (
|
||||
<Hammer className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
) : (
|
||||
<ClipboardList className="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-300" aria-hidden />
|
||||
)}
|
||||
<span>{isPlanning ? "Switch to standard" : "Switch to planning"}</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
{canToggleWorkMode && isPlanning ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="issue-chat-composer-work-mode-toggle"
|
||||
data-pending-work-mode={pendingWorkMode}
|
||||
aria-pressed
|
||||
title="Planning mode is on for this submission. Click to switch to Standard."
|
||||
onClick={() => setPendingWorkMode("standard")}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-amber-500/60 bg-amber-500/15 px-2 py-1 text-xs text-amber-800 transition-colors 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"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<ClipboardList className="h-3.5 w-3.5" aria-hidden />
|
||||
<span>Planning</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{enableReassign && reassignOptions.length > 0 ? (
|
||||
<InlineEntitySelector
|
||||
|
|
@ -3300,6 +3375,8 @@ export function IssueChatThread({
|
|||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
composerRef,
|
||||
issueWorkMode,
|
||||
onWorkModeChange,
|
||||
onRefreshLatestComments,
|
||||
}: IssueChatThreadProps) {
|
||||
const location = useLocation();
|
||||
|
|
@ -3873,8 +3950,8 @@ export function IssueChatThread({
|
|||
stoppingRunId={stoppingRunId}
|
||||
interruptingQueuedRunId={interruptingQueuedRunId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
))
|
||||
)}
|
||||
{showComposer ? (
|
||||
<div data-testid="issue-chat-thread-notices" className="space-y-2">
|
||||
<IssueBlockedNotice
|
||||
|
|
@ -3923,6 +4000,8 @@ export function IssueChatThread({
|
|||
composerDisabledReason={composerDisabledReason}
|
||||
composerHint={composerHint}
|
||||
issueStatus={issueStatus}
|
||||
issueWorkMode={issueWorkMode}
|
||||
onWorkModeChange={onWorkModeChange}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue