[codex] Add ask issue work mode (#8334)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Issue work mode controls how a task starts and how the conversation
composer frames the operator's intent.
> - Paperclip already supports standard agent execution and planning
mode, but there is no lightweight mode for asking a question without
immediately implying execution or plan drafting.
> - That gap makes low-commitment clarification workflows look like
normal task execution.
> - This pull request adds an explicit Ask mode and threads it through
shared contracts, server heartbeat context, and the issue composer UI.
> - The benefit is that operators can create or switch a task into a
question-oriented mode while preserving existing agent and planning
flows.

## Linked Issues or Issue Description

No public GitHub issue exists for this change. Inline feature request
follows the repository feature request template.

### Subsystem affected

Cross-cutting: `packages/shared`, `server/`, and `ui/`.

### Problem or motivation

Issue conversations currently distinguish standard agent work from
planning work, but question-first conversations do not have a clear
public mode in the shared contract or UI. Operators who want to ask an
agent a focused question have to use standard mode, which can imply
normal task execution, or planning mode, which asks for a plan rather
than an answer.

### Proposed solution

Add Ask as a first-class issue work mode. It should be selectable from
issue creation and issue chat, cycle alongside Standard and Planning
from the keyboard shortcut/menu, appear distinctly in composer styling,
and be included in heartbeat context so agents know to answer directly
instead of executing or drafting a plan.

### Alternatives considered

- Keep using standard mode for questions: rejected because it does not
communicate answer-only intent to the agent or the UI.
- Reuse planning mode for questions: rejected because planning mode asks
for a plan and is semantically different from asking a question.
- Add only local UI copy: rejected because the mode needs to be
represented in the shared contract and server heartbeat context to be
reliable.

### Roadmap alignment

This is a focused issue-workflow improvement. `ROADMAP.md` was checked
and no duplicate planned core work was found.

### Additional context

Related public searches performed before opening this PR:

- GitHub PR search for `"ask mode" repo:paperclipai/paperclip`
- GitHub issue search for `"ask mode" repo:paperclipai/paperclip`
- GitHub PR search for `"work mode" "ask" repo:paperclipai/paperclip`

No duplicate PR was found.

## What Changed

- Added `ask` to the shared issue work-mode contract and validation
coverage.
- Included issue work mode in heartbeat context summaries so agents can
see standard, planning, and ask state.
- Added Ask mode metadata, styling, composer tone handling, and
selection/cycling behavior in the issue chat/new issue UI.
- Updated focused tests for shared validators, heartbeat context, and
affected UI work-mode flows.

## Verification

- `NODE_ENV=test pnpm exec vitest run
ui/src/components/ChatComposer.test.tsx
ui/src/components/IssueChatThread.test.tsx
ui/src/components/NewIssueDialog.test.tsx
ui/src/lib/work-mode-meta.test.ts`
- `NODE_ENV=test pnpm exec vitest run
packages/shared/src/validators/issue.test.ts
server/src/__tests__/heartbeat-context-summary.test.ts
server/src/__tests__/issues-service.test.ts
ui/src/components/ChatComposer.test.tsx
ui/src/components/IssueChatThread.test.tsx
ui/src/components/NewIssueDialog.test.tsx
ui/src/lib/work-mode-meta.test.ts ui/src/pages/IssueDetail.test.tsx`

The broader targeted command passed 8 test files / 245 tests.

Visual reference for Standard/Planning/Ask composer states:
https://gist.github.com/cryppadotta/714d8590bac55500a65e7e16de5bb4b8

It emitted an expected warning from an existing server test fixture
about a missing run-log fixture while verifying derived issue comment
metadata.

## Risks

Low to moderate risk. This adds a new enum value that crosses shared,
server, and UI contracts. Existing standard and planning modes are
preserved, but any downstream code assuming only two non-terminal work
modes may need to handle `ask`.

> 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 GPT-5 Codex coding agent in Paperclip CodexCoder mode, with
shell, git, GitHub connector, and local test execution tools. Context
window and exact hosted model snapshot are 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`,
`feat/...`) and contains no internal Paperclip ticket id or
instance-derived details
- [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] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [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-06-19 13:06:04 -05:00
committed by GitHub
parent aeea5f9195
commit 7069053a1f
18 changed files with 509 additions and 122 deletions
+11
View File
@@ -188,6 +188,17 @@ describe("ChatComposer", () => {
act(() => root.unmount());
});
it('tone="ask" is reflected on the container', () => {
const root = createRoot(container);
act(() => {
root.render(<Harness tone="ask" />);
});
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);
+5 -2
View File
@@ -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<ChatComposerHandle, ChatComposerProps>(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<ChatComposerHandle, ChatComposerProps>(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",
+68 -3
View File
@@ -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(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
issueWorkMode="standard"
onWorkModeChange={onWorkModeChange}
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
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 =
+47 -36
View File
@@ -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<IssueChatComposerHandle, IssueChatComposerP
);
}
const isPlanning = pendingWorkMode === "planning";
const workModeOptions = workModeMetaList(true);
const pendingWorkModeMeta = workModeMetaFor(pendingWorkMode, true);
const PendingWorkModeIcon = pendingWorkModeMeta.icon;
function handleComposerKeyDown(evt: ReactKeyboardEvent<HTMLDivElement>) {
if (!(evt.metaKey || evt.ctrlKey) || evt.code !== "Period") return;
evt.preventDefault();
setPendingWorkMode((current) => nextWorkMode(current, true));
}
return (
<div
@@ -3727,9 +3737,10 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
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]",
pendingWorkModeMeta.classes.container,
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)]",
)}
onKeyDownCapture={handleComposerKeyDown}
onDragEnterCapture={handleFileDragEnter}
onDragOverCapture={handleFileDragOver}
onDragLeaveCapture={handleFileDragLeave}
@@ -3865,24 +3876,16 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
data-pending-work-mode={pendingWorkMode}
aria-haspopup="menu"
aria-expanded={workModeMenuOpen}
title={
isPlanning
? "Plan mode is on for this submission. Click to change."
: "Agent mode for this submission. Click to switch to plan mode."
}
aria-pressed={pendingWorkMode !== "standard"}
aria-keyshortcuts="Meta+Period Control+Period"
title={titleForPendingWorkMode(pendingWorkMode, true)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-semibold transition-colors",
isPlanning
? "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 bg-muted/40 text-muted-foreground hover:bg-accent hover:text-foreground",
pendingWorkModeMeta.classes.chip,
)}
>
{isPlanning ? (
<ClipboardList className="h-3.5 w-3.5" aria-hidden />
) : (
<Hammer className="h-3.5 w-3.5" aria-hidden />
)}
<span>{isPlanning ? "Plan mode" : "Agent mode"}</span>
<PendingWorkModeIcon className="h-3.5 w-3.5" aria-hidden />
<span>{pendingWorkModeMeta.label}</span>
<ChevronDown className="h-3 w-3 opacity-60" aria-hidden />
</button>
</PopoverTrigger>
@@ -3891,26 +3894,34 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
align="start"
data-testid="issue-chat-composer-work-mode-menu"
>
<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-foreground" : "text-amber-700 dark:text-amber-300",
)}
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 agent mode" : "Switch to plan mode"}</span>
</button>
{workModeOptions.map((option) => {
const Icon = option.icon;
const active = option.value === pendingWorkMode;
return (
<button
key={option.value}
type="button"
data-testid={`issue-chat-composer-work-mode-menu-${option.value}`}
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",
active && "bg-accent",
option.classes.menuItem,
)}
onClick={() => {
setPendingWorkMode(option.value);
setWorkModeMenuOpen(false);
}}
>
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span>{option.label}</span>
{active ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
</button>
);
})}
<div className="mt-1 border-t px-2 py-1.5 text-[10px] text-muted-foreground">
Cmd/Ctrl+. cycles modes
</div>
</PopoverContent>
</Popover>
) : null}
+58 -39
View File
@@ -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<IssueChatComposerHandle, IssueChatComposerP
);
}
const isPlanning = pendingWorkMode === "planning";
const workModeOptions = workModeMetaList(false);
const pendingWorkModeMeta = workModeMetaFor(pendingWorkMode, false);
const PendingWorkModeIcon = pendingWorkModeMeta.icon;
function handleComposerKeyDown(evt: ReactKeyboardEvent<HTMLDivElement>) {
if (!(evt.metaKey || evt.ctrlKey) || evt.code !== "Period") return;
evt.preventDefault();
setPendingWorkMode((current) => nextWorkMode(current, false));
}
return (
<div
@@ -3589,9 +3599,10 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
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]",
pendingWorkModeMeta.classes.container,
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)]",
)}
onKeyDownCapture={handleComposerKeyDown}
onDragEnterCapture={handleFileDragEnter}
onDragOverCapture={handleFileDragOver}
onDragLeaveCapture={handleFileDragLeave}
@@ -3704,53 +3715,61 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
{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-testid="issue-chat-composer-work-mode-toggle"
data-pending-work-mode={pendingWorkMode}
aria-haspopup="menu"
aria-expanded={workModeMenuOpen}
aria-pressed={pendingWorkMode !== "standard"}
aria-keyshortcuts="Meta+Period Control+Period"
title={titleForPendingWorkMode(pendingWorkMode, false)}
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",
"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors",
pendingWorkModeMeta.classes.chip,
)}
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>
<PendingWorkModeIcon className="h-3.5 w-3.5" aria-hidden />
<span>{pendingWorkModeMeta.label}</span>
<ChevronDown className="h-3 w-3 opacity-60" aria-hidden />
</button>
</PopoverTrigger>
<PopoverContent
className="w-44 p-1"
align="start"
data-testid="issue-chat-composer-work-mode-menu"
>
{workModeOptions.map((option) => {
const Icon = option.icon;
const active = option.value === pendingWorkMode;
return (
<button
key={option.value}
type="button"
data-testid={`issue-chat-composer-work-mode-menu-${option.value}`}
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",
active && "bg-accent",
option.classes.menuItem,
)}
onClick={() => {
setPendingWorkMode(option.value);
setWorkModeMenuOpen(false);
}}
>
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span>{option.label}</span>
{active ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
</button>
);
})}
<div className="mt-1 border-t px-2 py-1.5 text-[10px] text-muted-foreground">
Cmd/Ctrl+. cycles modes
</div>
</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"
>
<ClipboardList className="h-3.5 w-3.5" aria-hidden />
<span>Planning</span>
</button>
) : null}
</div>
{enableReassign && reassignOptions.length > 0 ? (
+115
View File
@@ -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.
+14 -26
View File
@@ -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() {
<button
type="button"
data-issue-work-mode-chip={workMode}
aria-keyshortcuts="Meta+Period Control+Period"
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",
currentWorkMode.classes.chip,
)}
>
<CurrentWorkModeIcon className="h-3 w-3" />
{currentWorkMode.label}
{currentWorkMode.shortLabel}
</button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start">
@@ -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() {
>
<Icon className="h-3 w-3" />
{option.label}
{option.value === workMode ? <Check className="ml-auto h-3 w-3" aria-hidden /> : null}
</button>
);
})}