- {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) ? (