[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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
{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-toggle"
|
||||
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",
|
||||
isPlanning ? "text-foreground" : "text-amber-700 dark:text-amber-300",
|
||||
active && "bg-accent",
|
||||
option.classes.menuItem,
|
||||
)}
|
||||
onClick={() => {
|
||||
setPendingWorkMode((prev) => (prev === "planning" ? "standard" : "planning"));
|
||||
setPendingWorkMode(option.value);
|
||||
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>
|
||||
<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}
|
||||
|
||||
@@ -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,52 +3715,60 @@ 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-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"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={workModeMenuOpen}
|
||||
aria-pressed={pendingWorkMode !== "standard"}
|
||||
aria-keyshortcuts="Meta+Period Control+Period"
|
||||
title={titleForPendingWorkMode(pendingWorkMode, false)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors",
|
||||
pendingWorkModeMeta.classes.chip,
|
||||
)}
|
||||
>
|
||||
<ClipboardList className="h-3.5 w-3.5" aria-hidden />
|
||||
<span>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}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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.`;
|
||||
}
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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");
|
||||
|
||||
@@ -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() {
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{issue.workMode === "planning" ? (
|
||||
{issue.workMode === "ask" || issue.workMode === "planning" ? (() => {
|
||||
const workModeMeta = workModeMetaFor(issue.workMode, conferenceRoomChatEnabled);
|
||||
const WorkModeIcon = workModeMeta.icon;
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300 shrink-0"
|
||||
title={conferenceRoomChatEnabled ? "This task is in plan mode." : "This task is in planning mode."}
|
||||
className={cn("inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium shrink-0", workModeMeta.classes.badge)}
|
||||
title={`This task is in ${workModeMeta.label.toLowerCase()}.`}
|
||||
>
|
||||
{conferenceRoomChatEnabled ? "Plan mode" : "Planning"}
|
||||
<WorkModeIcon className="h-3 w-3" aria-hidden />
|
||||
{workModeMeta.label}
|
||||
</span>
|
||||
) : null}
|
||||
);
|
||||
})() : null}
|
||||
|
||||
{hasAssignedBacklogBlocker(issue.blockedBy) ? (
|
||||
<span
|
||||
|
||||
Reference in New Issue
Block a user