diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 2c43e572..8386776e 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -233,6 +233,10 @@ Routine execution issues add a routine-scoped env overlay after project env and - identifier fields: `issue_number`, `identifier` - origin fields: `origin_kind`, `origin_id`, `origin_run_id`, `origin_fingerprint` - `request_depth` int not null default 0 +- `work_mode` text not null default `standard`; supported values: + - `standard`: normal autonomous execution. Agents may investigate, edit files, create artifacts, and complete the task. + - `ask`: answer-only execution. Agents may use tools for investigation or temporary scratch work, but the deliverable is an issue-thread answer; they must not write implementation code or produce an implementation plan. + - `planning`: plan-only execution. Agents create or revise the plan without implementation work; accepted-plan continuations remain planning-specific and create child issues from the approved plan. - `billing_code` text null - `assignee_adapter_overrides` jsonb null - `execution_policy` jsonb null diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 160713a5..a386efc1 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -197,7 +197,7 @@ 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 const ISSUE_WORK_MODES = ["standard", "ask", "planning"] as const; export type IssueWorkMode = (typeof ISSUE_WORK_MODES)[number]; export const MAX_ISSUE_REQUEST_DEPTH = 1024; diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 8ade9897..e05eef62 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -227,10 +227,17 @@ describe("issue validators", () => { }).status).toBe("backlog"); }); - it("defaults issue work mode to standard and accepts planning", () => { + it("defaults issue work mode to standard and accepts ask and planning", () => { expect(createIssueSchema.parse({ title: "Plan first" }).workMode).toBe("standard"); + expect(createIssueSchema.parse({ title: "Ask first", workMode: "ask" }).workMode).toBe("ask"); expect(createIssueSchema.parse({ title: "Plan first", workMode: "planning" }).workMode).toBe("planning"); + expect(updateIssueSchema.parse({ workMode: "ask" }).workMode).toBe("ask"); expect(updateIssueSchema.parse({ workMode: "planning" }).workMode).toBe("planning"); + expect(suggestedTaskDraftSchema.parse({ + clientKey: "ask-child", + title: "Ask child", + workMode: "ask", + }).workMode).toBe("ask"); expect(suggestedTaskDraftSchema.parse({ clientKey: "planning-child", title: "Plan child", diff --git a/server/src/__tests__/heartbeat-context-summary.test.ts b/server/src/__tests__/heartbeat-context-summary.test.ts index 1fa9b800..567317ea 100644 --- a/server/src/__tests__/heartbeat-context-summary.test.ts +++ b/server/src/__tests__/heartbeat-context-summary.test.ts @@ -72,6 +72,24 @@ describe("buildPaperclipTaskMarkdown", () => { expect(acceptedConfirmation).not.toContain("- Work mode: \"planning\""); }); + it("adds answer-only guidance for ask-mode issues", () => { + const assignment = buildPaperclipTaskMarkdown({ + issue: { + id: "issue-ask", + identifier: "PAP-416", + title: "Explain the tradeoff", + workMode: "ask", + description: null, + }, + }); + + expect(assignment).toContain("- Work mode: \"ask\""); + expect(assignment).toContain("Ask mode directive:"); + expect(assignment).toContain("Answer the question directly in the issue thread."); + expect(assignment).toContain("Do not write implementation code"); + expect(assignment).toContain("do not produce an implementation plan"); + }); + it("prefers ordinary comment planning guidance over stale accepted confirmation state", () => { const commentWake = buildPaperclipTaskMarkdown({ issue: { diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 1c47f334..44e63a74 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -36,7 +36,7 @@ import { ISSUE_LIST_MAX_LIMIT, issueService, } from "../services/issues.ts"; -import { buildAgentMentionHref, buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH } from "@paperclipai/shared"; +import { buildAgentMentionHref, buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH, type IssueWorkMode } from "@paperclipai/shared"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -4578,7 +4578,7 @@ describeEmbeddedPostgres("accepted plan decomposition", () => { assigneeAgentId?: string; sourceIssueId?: string; issueTitle?: string; - workMode?: "planning" | "standard"; + workMode?: IssueWorkMode; }) { const companyId = args?.companyId ?? randomUUID(); const goalId = args?.goalId ?? randomUUID(); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index dc089b71..a1ec920c 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2886,7 +2886,14 @@ export function buildPaperclipTaskMarkdown(input: { `- Issue: ${quoteTaskScalar(issue.identifier || issue.id)}`, `- Title: ${quoteTaskScalar(issue.title)}`, ); - if (issue.workMode === "planning") { + if (issue.workMode === "ask") { + lines.push( + `- Work mode: ${quoteTaskScalar("ask")}`, + "", + "Ask mode directive:", + "Answer the question directly in the issue thread. Do not write implementation code, and do not produce an implementation plan. Use tools only for investigation or temporary scratch work when needed; the deliverable is the answer.", + ); + } else 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."; diff --git a/tests/e2e/planning-mode-visual-verification.spec.ts b/tests/e2e/planning-mode-visual-verification.spec.ts index 5670c3d3..aa3c557a 100644 --- a/tests/e2e/planning-mode-visual-verification.spec.ts +++ b/tests/e2e/planning-mode-visual-verification.spec.ts @@ -135,8 +135,10 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => { await page.goto(issuePath); await page.getByTestId("issue-chat-composer-work-mode-toggle").click(); + await page.getByTestId("issue-chat-composer-work-mode-menu-standard").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 expect(page.getByTestId("issue-chat-composer-work-mode-toggle")).toHaveAttribute("data-pending-work-mode", "standard"); + await expect(page.getByTestId("issue-chat-composer-work-mode-toggle")).toHaveAttribute("aria-pressed", "false"); await page.screenshot({ path: `${screenshotDir}/desktop-standard-toggle-${timestamp}.png`, fullPage: true, diff --git a/ui/src/components/ChatComposer.test.tsx b/ui/src/components/ChatComposer.test.tsx index 8470d485..423db038 100644 --- a/ui/src/components/ChatComposer.test.tsx +++ b/ui/src/components/ChatComposer.test.tsx @@ -188,6 +188,17 @@ describe("ChatComposer", () => { act(() => root.unmount()); }); + it('tone="ask" is reflected on the container', () => { + const root = createRoot(container); + act(() => { + root.render(); + }); + const box = container.querySelector('[data-testid="chat-composer"]'); + expect(box?.getAttribute("data-tone")).toBe("ask"); + expect(box?.className).toContain("sky"); + act(() => root.unmount()); + }); + it('defaults to the opaque "card" surface', () => { // PAP-131: surface is opt-in — existing adopters keep the bg-card box. const root = createRoot(container); diff --git a/ui/src/components/ChatComposer.tsx b/ui/src/components/ChatComposer.tsx index 7791e8fd..82261620 100644 --- a/ui/src/components/ChatComposer.tsx +++ b/ui/src/components/ChatComposer.tsx @@ -58,8 +58,8 @@ export interface ChatComposerProps { submitKey?: "enter" | "mod-enter"; /** Collapse to a single visual line — strips newlines and disables wrapping (conference room). */ singleLine?: boolean; - /** Visual tone. `"planning"` tints the whole box amber (task planning mode, PAP-95b). */ - tone?: "standard" | "planning"; + /** Visual tone. Task issue modes tint the box for planning and ask flows. */ + tone?: "standard" | "ask" | "planning"; /** * Surface treatment (PAP-128 A / PAP-131). * - `"card"`: opaque `bg-card` box (default). @@ -141,6 +141,7 @@ export const ChatComposer = forwardRef(fu const [isDragOver, setIsDragOver] = useState(false); const canAttach = typeof onAttachFiles === "function"; + const isAsk = tone === "ask"; const isPlanning = tone === "planning"; const canSend = !disabled && !submitting && value.trim().length > 0; @@ -233,6 +234,8 @@ export const ChatComposer = forwardRef(fu ? "border-border/70 bg-background/95 shadow-[0_-12px_28px_rgba(15,23,42,0.08)] backdrop-blur supports-[backdrop-filter]:bg-background/85 dark:shadow-[0_-12px_28px_rgba(0,0,0,0.28)]" : "border-border bg-card", // No blue focus ring — neutral border darkening only. + isAsk && + "border-sky-500/55 bg-sky-50/50 focus-within:border-sky-500/70 dark:border-sky-500/50 dark:bg-sky-500/[0.07]", isPlanning && "border-amber-500/55 bg-amber-50/50 focus-within:border-amber-500/70 dark:border-amber-500/50 dark:bg-amber-500/[0.07]", isDragOver && canAttach && "border-muted-foreground/50 bg-accent/20", diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 799c000c..69950680 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -544,6 +544,7 @@ describe("IssueChatThread", () => { ); expect(toggle).not.toBeNull(); expect(toggle?.getAttribute("data-pending-work-mode")).toBe("planning"); + expect(toggle?.getAttribute("aria-pressed")).toBe("true"); expect(toggle?.textContent).toContain("Plan mode"); act(() => { @@ -551,7 +552,7 @@ describe("IssueChatThread", () => { }); }); - it("shows a persistent neutral mode chip on a standard issue and toggles to planning through its menu", () => { + it("shows a persistent neutral mode chip on a standard issue and selects planning through its menu", () => { const root = createRoot(container); const onWorkModeChange = vi.fn(); @@ -589,10 +590,10 @@ describe("IssueChatThread", () => { }); const menuItem = document.querySelector( - '[data-testid="issue-chat-composer-work-mode-menu-toggle"]', + '[data-testid="issue-chat-composer-work-mode-menu-planning"]', ) as HTMLButtonElement | null; expect(menuItem).not.toBeNull(); - expect(menuItem?.textContent).toContain("Switch to plan mode"); + expect(menuItem?.textContent).toContain("Plan mode"); act(() => { menuItem?.click(); @@ -609,6 +610,70 @@ describe("IssueChatThread", () => { }); }); + it("selects ask mode from the composer menu and cycles work modes with cmd-period", () => { + const root = createRoot(container); + const onWorkModeChange = vi.fn(); + + act(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const chip = container.querySelector( + '[data-testid="issue-chat-composer-work-mode-toggle"]', + ) as HTMLButtonElement | null; + const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null; + expect(chip).not.toBeNull(); + expect(composer).not.toBeNull(); + + act(() => { + chip?.click(); + }); + + const askMenuItem = document.querySelector( + '[data-testid="issue-chat-composer-work-mode-menu-ask"]', + ) as HTMLButtonElement | null; + expect(askMenuItem).not.toBeNull(); + expect(askMenuItem?.textContent).toContain("Ask mode"); + + act(() => { + askMenuItem?.click(); + }); + + expect(onWorkModeChange).not.toHaveBeenCalled(); + expect(composer?.getAttribute("data-pending-work-mode")).toBe("ask"); + expect(composer?.className).toContain("sky"); + expect(chip?.textContent).toContain("Ask mode"); + + act(() => { + composer?.dispatchEvent(new KeyboardEvent("keydown", { + bubbles: true, + code: "Period", + key: ".", + metaKey: true, + })); + }); + + expect(composer?.getAttribute("data-pending-work-mode")).toBe("standard"); + expect(chip?.textContent).toContain("Agent mode"); + + 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 2c60dcd4..263c99be 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -25,6 +25,7 @@ import { type ChangeEvent, type DragEvent as ReactDragEvent, type ErrorInfo, + type KeyboardEvent as ReactKeyboardEvent, type Ref, type ReactNode, } from "react"; @@ -159,6 +160,7 @@ import { } from "../lib/transcriptPresentation"; import { buildAgentMentionHref } from "@paperclipai/shared"; import { cn, formatDateTime, formatShortDate } from "../lib/utils"; +import { nextWorkMode, titleForPendingWorkMode, workModeMetaFor, workModeMetaList } from "../lib/work-mode-meta"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Textarea } from "@/components/ui/textarea"; @@ -3718,7 +3720,15 @@ const IssueChatComposer = forwardRef) { + if (!(evt.metaKey || evt.ctrlKey) || evt.code !== "Period") return; + evt.preventDefault(); + setPendingWorkMode((current) => nextWorkMode(current, true)); + } return (
- {isPlanning ? ( - - ) : ( - - )} - {isPlanning ? "Plan mode" : "Agent mode"} + + {pendingWorkModeMeta.label} @@ -3891,26 +3894,34 @@ const IssueChatComposer = forwardRef - + {workModeOptions.map((option) => { + const Icon = option.icon; + const active = option.value === pendingWorkMode; + return ( + + ); + })} +
+ Cmd/Ctrl+. cycles modes +
) : null} diff --git a/ui/src/components/IssueChatThreadClassic.tsx b/ui/src/components/IssueChatThreadClassic.tsx index eb4736cb..0da6f11c 100644 --- a/ui/src/components/IssueChatThreadClassic.tsx +++ b/ui/src/components/IssueChatThreadClassic.tsx @@ -36,6 +36,7 @@ import { type ChangeEvent, type DragEvent as ReactDragEvent, type ErrorInfo, + type KeyboardEvent as ReactKeyboardEvent, type Ref, type ReactNode, } from "react"; @@ -146,6 +147,7 @@ import { summarizeToolResult, } from "../lib/transcriptPresentation"; import { cn, formatDateTime, formatShortDate } from "../lib/utils"; +import { nextWorkMode, titleForPendingWorkMode, workModeMetaFor, workModeMetaList } from "../lib/work-mode-meta"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Textarea } from "@/components/ui/textarea"; @@ -3580,7 +3582,15 @@ const IssueChatComposer = forwardRef) { + if (!(evt.metaKey || evt.ctrlKey) || evt.code !== "Period") return; + evt.preventDefault(); + setPendingWorkMode((current) => nextWorkMode(current, false)); + } return (
- - - + + + {workModeOptions.map((option) => { + const Icon = option.icon; + const active = option.value === pendingWorkMode; + return ( + + ); + })} +
+ Cmd/Ctrl+. cycles modes +
) : null} - {canToggleWorkMode && isPlanning ? ( - - ) : null}
{enableReassign && reassignOptions.length > 0 ? ( diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index 97b2f0e5..794d6b47 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -482,6 +482,41 @@ describe("NewIssueDialog", () => { act(() => root.unmount()); }); + it("restores ask mode from dialog defaults", async () => { + dialogState.newIssueDefaults = { + title: "Question from defaults", + workMode: "ask", + }; + + const { root } = renderDialog(container); + await flush(); + + const askButton = container.querySelector('[data-issue-work-mode="ask"]'); + expect(askButton?.className).toContain("bg-accent"); + + const submitButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Create Task")); + 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: "Question from defaults", + workMode: "ask", + }), + ); + + act(() => root.unmount()); + }); + it("applies project and execution workspace defaults for normal new issues", async () => { mockProjectsApi.list.mockResolvedValue([ { @@ -705,6 +740,84 @@ describe("NewIssueDialog", () => { act(() => root.unmount()); }); + it("submits ask work mode when ask is selected", async () => { + const { root } = renderDialog(container); + await flush(); + + const titleInput = container.querySelector('textarea[placeholder="Task title"]') as HTMLTextAreaElement | null; + expect(titleInput).not.toBeNull(); + await typeTextareaValue(titleInput!, "Answer this first"); + + const askButton = container.querySelector('[data-issue-work-mode="ask"]'); + expect(askButton).not.toBeNull(); + await act(async () => { + askButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + const submitButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Create Task")); + 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: "Answer this first", + workMode: "ask", + }), + ); + + act(() => root.unmount()); + }); + + it("cycles work modes with cmd-period", async () => { + const { root } = renderDialog(container); + await flush(); + + const modeChip = () => container.querySelector("[data-issue-work-mode-chip]"); + expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("standard"); + + await act(async () => { + modeChip()?.dispatchEvent(new KeyboardEvent("keydown", { + bubbles: true, + code: "Period", + key: ".", + metaKey: true, + })); + }); + expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning"); + + await act(async () => { + modeChip()?.dispatchEvent(new KeyboardEvent("keydown", { + bubbles: true, + code: "Period", + key: ".", + metaKey: true, + })); + }); + expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("ask"); + + await act(async () => { + modeChip()?.dispatchEvent(new KeyboardEvent("keydown", { + bubbles: true, + code: "Period", + key: ".", + metaKey: true, + })); + }); + expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("standard"); + + act(() => root.unmount()); + }); + it("submits the parent assignee when a sub-issue opens with inherited defaults", async () => { dialogState.newIssueDefaults = { parentId: "issue-1", @@ -905,6 +1018,7 @@ describe("NewIssueDialog", () => { expect(workModeOption("standard")?.textContent).toContain("Standard"); expect(workModeOption("standard")?.textContent).not.toContain("Agent mode"); + expect(workModeOption("ask")?.textContent).toContain("Ask"); expect(workModeOption("planning")?.textContent).toContain("Planning"); expect(workModeOption("planning")?.textContent).not.toContain("Plan mode"); @@ -922,6 +1036,7 @@ describe("NewIssueDialog", () => { await flush(); expect(workModeOption("standard")?.textContent).toContain("Agent mode"); + expect(workModeOption("ask")?.textContent).toContain("Ask mode"); expect(workModeOption("planning")?.textContent).toContain("Plan mode"); // PAP-75 brand palette: todo → amber, in_progress → blue. diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 234765ec..7f05184b 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -20,6 +20,7 @@ import { useProjectOrder } from "../hooks/useProjectOrder"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects"; import { buildExecutionPolicy } from "../lib/issue-execution-policy"; +import { isIssueWorkMode, nextWorkMode, workModeMetaFor, workModeMetaList } from "../lib/work-mode-meta"; import { useToastActions } from "../context/ToastContext"; import { assigneeValueFromSelection, @@ -43,9 +44,8 @@ import { MoreHorizontal, ChevronRight, ChevronDown, + Check, CircleDot, - ClipboardList, - Hammer, Minus, ArrowUp, ArrowDown, @@ -139,23 +139,6 @@ const ISSUE_THINKING_EFFORT_OPTIONS = { ], } as const; -function isIssueWorkMode(value: unknown): value is IssueWorkMode { - return value === "standard" || value === "planning"; -} - -// "Agent mode"/"Plan mode" relabels ship behind the Conference Room Chat flag -// (PAP-132/PAP-139); OFF keeps master's "Standard"/"Planning". -function issueWorkModeOptions(conferenceRoomChat: boolean): ReadonlyArray<{ - value: IssueWorkMode; - label: string; - icon: typeof Hammer; -}> { - return [ - { value: "standard", label: conferenceRoomChat ? "Agent mode" : "Standard", icon: Hammer }, - { value: "planning", label: conferenceRoomChat ? "Plan mode" : "Planning", icon: ClipboardList }, - ]; -} - function loadDraft(): IssueDraft | null { try { const raw = localStorage.getItem(DRAFT_KEY); @@ -420,7 +403,7 @@ export function NewIssueDialog() { const { companies, selectedCompanyId, selectedCompany } = useCompany(); // Conference Room Chat flag (PAP-139): selects work-mode labels + status hues. const { enabled: conferenceRoomChatEnabled } = useConferenceRoomChatEnabled(); - const workModeOptions = useMemo(() => issueWorkModeOptions(conferenceRoomChatEnabled), [conferenceRoomChatEnabled]); + const workModeOptions = useMemo(() => workModeMetaList(conferenceRoomChatEnabled), [conferenceRoomChatEnabled]); const statuses = useMemo(() => buildStatusOptions(conferenceRoomChatEnabled), [conferenceRoomChatEnabled]); const queryClient = useQueryClient(); const { pushToast } = useToastActions(); @@ -1018,6 +1001,11 @@ export function NewIssueDialog() { } function handleKeyDown(e: React.KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.code === "Period") { + e.preventDefault(); + setWorkMode((current) => nextWorkMode(current, conferenceRoomChatEnabled)); + return; + } if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); handleSubmit(); @@ -1209,7 +1197,7 @@ export function NewIssueDialog() { }, [assigneeAdapterModels], ); - const currentWorkMode = workModeOptions[workMode === "planning" ? 1 : 0]!; + const currentWorkMode = workModeMetaFor(workMode, conferenceRoomChatEnabled); const CurrentWorkModeIcon = currentWorkMode.icon; return ( @@ -1956,15 +1944,14 @@ export function NewIssueDialog() { @@ -1977,7 +1964,7 @@ export function NewIssueDialog() { 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", + option.classes.menuItem, )} onClick={() => { setWorkMode(option.value); @@ -1986,6 +1973,7 @@ export function NewIssueDialog() { > {option.label} + {option.value === workMode ? : null} ); })} diff --git a/ui/src/lib/work-mode-meta.test.ts b/ui/src/lib/work-mode-meta.test.ts new file mode 100644 index 00000000..daacea0c --- /dev/null +++ b/ui/src/lib/work-mode-meta.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { nextWorkMode, titleForPendingWorkMode, workModeMetaList } from "./work-mode-meta"; + +describe("work mode metadata", () => { + it("orders issue work modes as agent, planning, then ask", () => { + expect(workModeMetaList(false).map((mode) => mode.value)).toEqual(["standard", "planning", "ask"]); + expect(workModeMetaList(true).map((mode) => mode.shortLabel)).toEqual(["Agent", "Plan", "Ask"]); + }); + + it("cycles issue work modes as agent, planning, ask, then agent", () => { + expect(nextWorkMode("standard", true)).toBe("planning"); + expect(nextWorkMode("planning", true)).toBe("ask"); + expect(nextWorkMode("ask", true)).toBe("standard"); + }); + + it("matches standard mode tooltip copy to the active surface", () => { + expect(titleForPendingWorkMode("standard", false)).toBe("Standard mode for this submission. Click to change."); + expect(titleForPendingWorkMode("standard", true)).toBe("Agent mode for this submission. Click to change."); + }); +}); diff --git a/ui/src/lib/work-mode-meta.ts b/ui/src/lib/work-mode-meta.ts new file mode 100644 index 00000000..45836587 --- /dev/null +++ b/ui/src/lib/work-mode-meta.ts @@ -0,0 +1,93 @@ +import type { IssueWorkMode } from "@paperclipai/shared"; +import { ClipboardList, Hammer, MessageCircleQuestion, type LucideIcon } from "lucide-react"; + +export type WorkModeTone = "neutral" | "ask" | "planning"; + +export interface WorkModeMeta { + value: IssueWorkMode; + label: string; + shortLabel: string; + icon: LucideIcon; + tone: WorkModeTone; + classes: { + chip: string; + container: string; + menuItem: string; + badge: string; + }; +} + +const STANDARD_CLASSES = { + chip: "border-border bg-muted/40 text-muted-foreground hover:bg-accent hover:text-foreground", + container: "", + menuItem: "text-foreground", + badge: "", +}; + +const ASK_CLASSES = { + chip: "border-sky-500/60 bg-sky-500/15 text-sky-800 hover:bg-sky-500/25 dark:border-sky-500/50 dark:bg-sky-500/15 dark:text-sky-200 dark:hover:bg-sky-500/25", + container: "border-sky-500/60 bg-sky-50/60 supports-[backdrop-filter]:bg-sky-50/40 dark:border-sky-500/50 dark:bg-sky-500/[0.07] dark:supports-[backdrop-filter]:bg-sky-500/[0.07]", + menuItem: "text-sky-700 dark:text-sky-300", + badge: "border-sky-500/40 bg-sky-50 text-sky-700 dark:border-sky-500/40 dark:bg-sky-500/10 dark:text-sky-200", +}; + +const PLANNING_CLASSES = { + chip: "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", + container: "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]", + menuItem: "text-amber-700 dark:text-amber-300", + badge: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300", +}; + +export function isIssueWorkMode(value: unknown): value is IssueWorkMode { + return value === "standard" || value === "ask" || value === "planning"; +} + +export function workModeMetaList(conferenceRoomChat: boolean): WorkModeMeta[] { + return [ + { + value: "standard", + label: conferenceRoomChat ? "Agent mode" : "Standard", + shortLabel: conferenceRoomChat ? "Agent" : "Standard", + icon: Hammer, + tone: "neutral", + classes: STANDARD_CLASSES, + }, + { + value: "planning", + label: conferenceRoomChat ? "Plan mode" : "Planning", + shortLabel: conferenceRoomChat ? "Plan" : "Planning", + icon: ClipboardList, + tone: "planning", + classes: PLANNING_CLASSES, + }, + { + value: "ask", + label: conferenceRoomChat ? "Ask mode" : "Ask", + shortLabel: "Ask", + icon: MessageCircleQuestion, + tone: "ask", + classes: ASK_CLASSES, + }, + ]; +} + +export function workModeMetaFor(mode: IssueWorkMode, conferenceRoomChat: boolean): WorkModeMeta { + const modes = workModeMetaList(conferenceRoomChat); + return modes.find((meta) => meta.value === mode) ?? modes[0]!; +} + +export function nextWorkMode(mode: IssueWorkMode, conferenceRoomChat: boolean): IssueWorkMode { + const modes = workModeMetaList(conferenceRoomChat); + const index = modes.findIndex((meta) => meta.value === mode); + return modes[(index + 1) % modes.length]?.value ?? "standard"; +} + +export function titleForPendingWorkMode(mode: IssueWorkMode, conferenceRoomChat: boolean): string { + if (mode === "ask") { + return "Ask mode for this submission. Click to change. The assignee will answer in this thread; no implementation work."; + } + if (mode === "planning") { + return `${conferenceRoomChat ? "Plan" : "Planning"} mode is on for this submission. Click to change.`; + } + return `${conferenceRoomChat ? "Agent" : "Standard"} mode for this submission. Click to change.`; +} diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index b8a1125d..961fba00 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -1637,6 +1637,23 @@ describe("IssueDetail", () => { expect(container.textContent).toContain("Plan mode"); }); + it("passes ask work mode to the issue chat thread and renders the ask badge", async () => { + mockIssuesApi.get.mockResolvedValue(createIssue({ workMode: "ask" })); + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({ + issueWorkMode: "ask", + }); + expect(container.textContent).toContain("Ask mode"); + }); + it("falls back to execCommand when copying the task from an insecure context", async () => { const clipboardWrite = vi.fn(async () => { throw new Error("Clipboard API blocked"); @@ -1833,11 +1850,11 @@ describe("IssueDetail", () => { expect(typeof lastChatThreadProps?.onWorkModeChange).toBe("function"); await act(async () => { - lastChatThreadProps?.onWorkModeChange?.("planning"); + lastChatThreadProps?.onWorkModeChange?.("ask"); }); await flushReact(); - expect(mockIssuesApi.update).toHaveBeenCalledWith(issue.identifier, { workMode: "planning" }); + expect(mockIssuesApi.update).toHaveBeenCalledWith(issue.identifier, { workMode: "ask" }); 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"); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 6a96cc04..4c10a19e 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -70,6 +70,7 @@ import { } from "../components/IssueChatThread"; import { IssueChatThreadClassic } from "../components/IssueChatThreadClassic"; import { useConferenceRoomChatEnabled } from "../hooks/useConferenceRoomChatEnabled"; +import { workModeMetaFor } from "../lib/work-mode-meta"; import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff"; import { IssueAttachmentsSection } from "../components/IssueAttachmentsSection"; import { IssueDocumentsSection } from "../components/IssueDocumentsSection"; @@ -3668,14 +3669,19 @@ export function IssueDetail() { ) : null} - {issue.workMode === "planning" ? ( - - {conferenceRoomChatEnabled ? "Plan mode" : "Planning"} - - ) : null} + {issue.workMode === "ask" || issue.workMode === "planning" ? (() => { + const workModeMeta = workModeMetaFor(issue.workMode, conferenceRoomChatEnabled); + const WorkModeIcon = workModeMeta.icon; + return ( + + + {workModeMeta.label} + + ); + })() : null} {hasAssignedBacklogBlocker(issue.blockedBy) ? (