diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 850081d3919..c30e2611582 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -103,6 +103,12 @@ pnpm build-storybook These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`. +**Composer → New task** includes agent/user/project/task mentions, skill and +routine slash commands, and populated rich chips on desktop and mobile. Agent +mentions use the same avatars in suggestions, inserted chips, and the mocked +creation receipt. The creation stories preserve the original Markdown references; +they do not create real tasks or run agents. + Use **Components → Agent setup prompt** to review the shared setup handoff: hover/focus logo motion, one-click copying with a prompt preview, animated confirmation, and manual-copy recovery. Opening the preview copies immediately; @@ -146,6 +152,41 @@ and submit the preserved draft. The composer has no question pending badge. The demo uses real thread/form components with fixture response callbacks. The Agent Chat stories share the same component fixture. +Use **Composer → New task** to review task creation through the production +`TaskChatComposer`. The editor, file menu, work modes, assignee/model picker, +and send control are shared with task chat. An inset bar above the composer uses +the queued-message container and holds Project on the left. Its searchable picker +shows project colors. Worktrees sits beside Project when the selected project has +isolation enabled. Choose a new worktree, reuse an eligible worktree from that +project, or keep the shared project workspace. Reuse carries the worktree's source +checkout, including a non-primary checkout. Changing projects clears the +previous worktree choice. Switching from reuse to a new worktree or the project +workspace restores the project default checkout. Task creation uses the selected company and has +no separate heading or settings control. +Fresh tasks start with an empty request and the last task assignee chosen in that +company, including a human. If that assignee is unavailable, the CEO is the +default, or the first eligible agent when the company has no CEO. Explicit launch +assignees and saved drafts keep their selection. Selecting an assignee never +inserts a mention. Clicking the assignee opens its searchable list directly; +model and effort have a separate trigger and appear only for agents whose +harness supports those settings. +Fresh tasks also keep the company's last project (including No project); launch +context and saved drafts take precedence. New projects receive a random palette +color and become the remembered project. The shared composer remembers the last +effort within the company and applies it only when the selected model supports +that level. Explicit task overrides and drafts take precedence. Default model +labels show the configured or known adapter model, otherwise simply Default. +Claude defaults supplied by the server's host environment remain Default unless +the agent explicitly configures the model. Project edits on an existing task +become the remembered project only after the task update succeeds. +Clicking or tapping outside either selector dismisses it and preserves the task +draft and selections. Mobile sheets return focus to the trigger without reopening. +Stories cover empty and prefilled drafts, remembered, human, and unassigned +selections, the direct assignee picker, sub-tasks, planning, files, saving, +retryable failures, creation, light theme, and mobile, plus worktree reuse, +loading, empty, error, and isolation-disabled states. Story submissions use local +fixtures and never start an agent. + Use **Composer → Model and effort picker** to review harness-specific model choices. Codex uses the curated adapter catalog unless the instance declares `PAPERCLIP_ADAPTER_MODELS`; general OpenAI API models are not Codex choices. diff --git a/tests/e2e/new-task-picker-touch.spec.ts b/tests/e2e/new-task-picker-touch.spec.ts index a78f25e3730..32cc9cf9ea9 100644 --- a/tests/e2e/new-task-picker-touch.spec.ts +++ b/tests/e2e/new-task-picker-touch.spec.ts @@ -7,8 +7,15 @@ async function json(response: APIResponse) { } async function swipeToLastOption(page: Page, picker: Locator) { - const list = picker.locator("[data-mobile-entity-picker-list]"); - const last = list.getByRole("button").last(); + const list = picker.getByRole("listbox"); + const last = list.getByRole("option").last(); + // The shared picker animates its height between views. Native touch + // coordinates must use the settled sheet rather than its clipped first frame. + await expect.poll(() => picker.evaluate((element) => { + const body = element.querySelector(".composer-run-settings-height"); + return [...element.getAnimations(), ...(body?.getAnimations() ?? [])] + .every((animation) => animation.playState !== "running"); + })).toBe(true); // visualViewport resize updates React state after the browser viewport changes. await expect.poll(async () => { const bounds = (await picker.boundingBox())!; @@ -73,50 +80,46 @@ test("new-task assignee and model sheets scroll by touch and retain the selected })); await page.goto(`/${company.issuePrefix}/dashboard`); await page.getByRole("navigation", { name: "Mobile navigation" }).getByRole("button", { name: "New Task", exact: true }).tap(); - await page.getByPlaceholder("Task title").fill("Keep this touch selection draft"); - await page.getByRole("button", { name: "Assignee", exact: true }).tap(); - const assignees = page.getByRole("dialog", { name: "Select assignee", exact: true }); - const lastAssignee = await swipeToLastOption(page, assignees); - await expect(lastAssignee).toHaveText("Touch Agent 22"); + const draft = page.getByRole("textbox", { name: "editable markdown" }); + await draft.fill("Keep this touch selection draft"); + const assigneeTrigger = page.getByRole("button", { name: "Select assignee", exact: true }); + const modelTrigger = page.getByRole("button", { name: "Select model and effort", exact: true }); + await assigneeTrigger.tap(); + const assigneePicker = page.getByRole("dialog", { name: "Select assignee", exact: true }); + const lastAssignee = await swipeToLastOption(page, assigneePicker); + await expect(lastAssignee).toContainText("Touch Agent 22"); await lastAssignee.tap(); - // Assignee confirmation advances to the project picker. - await page.getByRole("dialog", { name: "Select project", exact: true }).getByRole("button", { name: "No project", exact: true }).tap(); - await expect(page.getByRole("button", { name: "Touch Agent 22", exact: true })).toBeVisible(); - - await page.getByRole("button", { name: "Codex options", exact: true }).tap(); - await page.getByRole("radio", { name: "Custom", exact: true }).tap(); - await page.getByRole("button", { name: "Default model", exact: true }).tap(); + await expect(assigneePicker).toBeHidden(); + await expect(assigneeTrigger).toBeFocused(); + await modelTrigger.tap(); + const picker = page.getByRole("dialog", { name: "Select model and effort", exact: true }); + await page.getByRole("button", { name: "Choose exact model" }).tap(); // A reduced viewport exercises the space available when a phone keyboard opens. await page.setViewportSize({ width: 390, height: 430 }); - const models = page.getByRole("dialog", { name: "Default model", exact: true }); - const lastModel = await swipeToLastOption(page, models); - await expect(lastModel).toHaveText("Touch Model 24"); + const lastModel = await swipeToLastOption(page, picker); + await expect(lastModel).toContainText("Touch Model 24"); await lastModel.tap(); + await page.getByRole("button", { name: "Close picker" }).tap(); await page.setViewportSize({ width: 390, height: 844 }); - await expect(page.getByRole("button", { name: "Touch Model 24", exact: true })).toBeVisible(); - await expect(page.getByPlaceholder("Task title")).toHaveValue("Keep this touch selection draft"); + await expect(assigneeTrigger).toContainText("Touch Agent 22"); + await expect(modelTrigger).toContainText("Touch Model 24"); + await expect(draft).toHaveText("Keep this touch selection draft"); - // Reopening and closing the nested modal must preserve the outer task draft. - await page.getByRole("button", { name: "Touch Model 24", exact: true }).tap(); - await page.getByRole("button", { name: "Close selector", exact: true }).tap(); - await expect(models).toBeHidden(); - await expect(page.getByRole("button", { name: "Touch Model 24", exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: "Touch Agent 22", exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: "Create Task", exact: true })).toBeEnabled(); - await page.getByRole("button", { name: "Touch Model 24", exact: true }).tap(); - await page.getByPlaceholder("Search models...").press("Escape"); - await expect(models).toBeHidden(); - await expect(page.getByRole("button", { name: "Touch Model 24", exact: true })).toBeFocused(); - await page.getByRole("button", { name: "Touch Model 24", exact: true }).tap(); - await page.touchscreen.tap(4, 4); - await expect(models).toBeHidden(); - await expect(page.getByPlaceholder("Task title")).toHaveValue("Keep this touch selection draft"); + // Closing the nested picker preserves the outer composer draft and choices. + await modelTrigger.tap(); + await page.getByRole("button", { name: "Choose exact model" }).tap(); + await page.getByRole("searchbox", { name: "Search or paste a model ID" }).press("Escape"); + await expect(picker).toBeHidden(); + await expect(assigneeTrigger).toBeFocused(); + await expect(page.getByRole("button", { name: "Create task", exact: true })).toBeEnabled(); await page.screenshot({ path: testInfo.outputPath("selected-mobile-assignee-and-model.png") }); - // The desktop popover keeps mouse opening and keyboard selection. + // The desktop picker uses the same selected values and supports model search. await page.setViewportSize({ width: 1280, height: 900 }); - await page.getByRole("button", { name: "Touch Model 24", exact: true }).click(); - await page.getByPlaceholder("Search models...").fill("Touch Model 01"); - await page.getByPlaceholder("Search models...").press("Enter"); - await expect(page.getByRole("button", { name: "Touch Model 01", exact: true })).toBeVisible(); + await modelTrigger.click(); + await page.getByRole("button", { name: "Choose exact model" }).click(); + await page.getByRole("searchbox", { name: "Search or paste a model ID" }).fill("Touch Model 01"); + await page.getByRole("option", { name: "Touch Model 01 touch-model-01", exact: true }).click(); + await page.getByRole("button", { name: "Choose exact model" }).press("Escape"); + await expect(modelTrigger).toContainText("Touch Model 01"); }); diff --git a/ui/src/components/InlineEntitySelector.test.tsx b/ui/src/components/InlineEntitySelector.test.tsx index bf221d605d8..78cf92d4ca2 100644 --- a/ui/src/components/InlineEntitySelector.test.tsx +++ b/ui/src/components/InlineEntitySelector.test.tsx @@ -139,6 +139,59 @@ describe("InlineEntitySelector", () => { } }); + it("dismisses the mobile project sheet from its backdrop and preserves the task dialog", async () => { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(max-width: 40rem)", + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); + const onChange = vi.fn(); + const root = createRoot(container); + await act(() => { + root.render( + + + New task + + + , + ); + }); + try { + const trigger = document.querySelector('[data-testid="project-picker"]')!; + await act(() => trigger.click()); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); + const backdrop = document.querySelector("[data-mobile-entity-picker]")!.parentElement!; + await act(() => { + const pointerDown = new MouseEvent("pointerdown", { bubbles: true, cancelable: true }); + Object.defineProperty(pointerDown, "pointerType", { value: "touch" }); + backdrop.dispatchEvent(pointerDown); + backdrop.click(); + }); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); + expect(document.querySelector("[data-mobile-entity-picker]")).toBeNull(); + expect(document.querySelector("[data-slot=dialog-content]")).not.toBeNull(); + expect(trigger.textContent).toContain("Board UI"); + expect(document.activeElement).toBe(trigger); + expect(onChange).not.toHaveBeenCalled(); + await act(() => trigger.click()); + expect(document.querySelector("[data-mobile-entity-picker]")).not.toBeNull(); + } finally { + await act(() => root.unmount()); + } + }); + it("keeps handled search navigation keys inside the popover", async () => { const root = createRoot(container); const onChange = vi.fn(); diff --git a/ui/src/components/InlineEntitySelector.tsx b/ui/src/components/InlineEntitySelector.tsx index 700b89bbc1f..31594ee6548 100644 --- a/ui/src/components/InlineEntitySelector.tsx +++ b/ui/src/components/InlineEntitySelector.tsx @@ -1,5 +1,5 @@ import { forwardRef, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; -import { Check, X } from "lucide-react"; +import { Check, Search, X } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { orderItemsBySelectedAndRecent } from "../lib/recent-selections"; import { cn } from "../lib/utils"; @@ -16,6 +16,8 @@ interface InlineEntitySelectorProps { options: InlineEntityOption[]; placeholder: string; noneLabel: string; + /** Keep the no-selection action after the project choices. */ + noneAtEnd?: boolean; searchPlaceholder: string; emptyMessage: string; onChange: (id: string) => void; @@ -68,6 +70,7 @@ export const InlineEntitySelector = forwardRef(() => { const baseOptions = [{ id: "", label: noneLabel, searchText: noneLabel }, ...options]; - return orderItemsBySelectedAndRecent(baseOptions, value, recentOptionIds); - }, [noneLabel, options, recentOptionIds, value]); + const ordered = orderItemsBySelectedAndRecent(baseOptions, value, recentOptionIds); + return noneAtEnd ? [...ordered.filter((option) => option.id), baseOptions[0]!] : ordered; + }, [noneAtEnd, noneLabel, options, recentOptionIds, value]); const filteredOptions = useMemo(() => { const term = query.trim().toLowerCase(); @@ -215,51 +219,54 @@ export const InlineEntitySelector = forwardRef - { - setQuery(event.target.value); - }} - onKeyDown={(event) => { - if (event.key === "ArrowDown") { - event.preventDefault(); - event.stopPropagation(); - setHighlightedIndexValue((current) => - filteredOptions.length === 0 ? 0 : (current + 1) % filteredOptions.length, - ); - return; - } - if (event.key === "ArrowUp") { - event.preventDefault(); - event.stopPropagation(); - setHighlightedIndexValue((current) => { - if (filteredOptions.length === 0) return 0; - return current <= 0 ? filteredOptions.length - 1 : current - 1; - }); - return; - } - if (event.key === "Enter") { - event.preventDefault(); - event.stopPropagation(); - commitSelection(highlightedIndexRef.current, true); - return; - } - if (event.key === "Tab" && !event.shiftKey) { - event.preventDefault(); - event.stopPropagation(); - commitSelection(highlightedIndexRef.current, true); - return; - } - if (event.key === "Escape") { - event.preventDefault(); - event.stopPropagation(); - setOpen(false); - } - }} - /> +
+ + { + setQuery(event.target.value); + }} + onKeyDown={(event) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + event.stopPropagation(); + setHighlightedIndexValue((current) => + filteredOptions.length === 0 ? 0 : (current + 1) % filteredOptions.length, + ); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + event.stopPropagation(); + setHighlightedIndexValue((current) => { + if (filteredOptions.length === 0) return 0; + return current <= 0 ? filteredOptions.length - 1 : current - 1; + }); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + commitSelection(highlightedIndexRef.current, true); + return; + } + if (event.key === "Tab" && !event.shiftKey) { + event.preventDefault(); + event.stopPropagation(); + commitSelection(highlightedIndexRef.current, true); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + } + }} + /> +
{filteredOptions.length === 0 ? (

{emptyMessage}

@@ -273,6 +280,7 @@ export const InlineEntitySelector = forwardRef setHighlightedIndexValue(index)} diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 69e36f8bccb..4a6b329a045 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -14,6 +14,7 @@ import type { import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue, IssueDocument } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getLastProjectId } from "../lib/recent-projects"; import { IssueProperties } from "./IssueProperties"; import { queryKeys } from "../lib/queryKeys"; @@ -1787,6 +1788,27 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); + it("leaves project memory unchanged until task property edits are persisted", async () => { + localStorage.clear(); + mockProjectsApi.list.mockResolvedValue([createProject({ name: "Remembered Project" })]); + const root = renderProperties(container, { + issue: createIssue(), childIssues: [], onUpdate: vi.fn(), inline: true, + }); + await flush(); + await act(() => findRowTrigger(container, "Project")!.click()); + const option = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Remembered Project")!; + expect(option).toBeDefined(); + act(() => option.click()); + expect(getLastProjectId("company-1")).toBeUndefined(); + await act(() => findRowTrigger(container, "Project")!.click()); + const none = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "No project")!; + act(() => none.click()); + expect(getLastProjectId("company-1")).toBeUndefined(); + expect(getLastProjectId("company-2")).toBeUndefined(); + act(() => root.unmount()); + localStorage.clear(); + }); + it("shows a green service link above the workspace row for a live non-main workspace", async () => { mockProjectsApi.list.mockResolvedValue([createProject()]); const serviceUrl = "http://127.0.0.1:62475"; diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 4f5e0124ab6..7d5aba00be0 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -11,6 +11,7 @@ import { buildRoutineMentionHref, buildSkillMentionHref, buildUserMentionHref, + appearanceForPalette, } from "@paperclipai/shared"; import { ThemeProvider } from "../context/ThemeContext"; import { MarkdownBody } from "./MarkdownBody"; @@ -51,7 +52,7 @@ vi.mock("../api/issues", () => ({ // Defaults to null (no provider) so the existing suite exercises the permissive // path unchanged. Gating tests override the return value per-case. -const mockUseOptionalCompany = vi.hoisted(() => vi.fn<() => { companies: Array<{ issuePrefix: string }> } | null>(() => null)); +const mockUseOptionalCompany = vi.hoisted(() => vi.fn<() => { companies: Array<{ issuePrefix: string }>; selectedCompanyId?: string } | null>(() => null)); vi.mock("../context/CompanyContext", () => ({ useOptionalCompany: mockUseOptionalCompany, @@ -173,7 +174,8 @@ describe("MarkdownBody", () => { expect(html).toContain('data-mention-kind="user"'); expect(html).toContain('href="/agents/agent-123"'); expect(html).toContain('data-mention-kind="agent"'); - expect(html).toContain("--paperclip-mention-icon-mask"); + expect(html).toContain("--paperclip-mention-avatar-image"); + expect(html).not.toContain("--paperclip-mention-icon-mask"); expect(html).toContain('href="/projects/project-456"'); expect(html).toContain('data-mention-kind="project"'); expect(html).toContain("--paperclip-mention-project-color:#336699"); @@ -191,6 +193,23 @@ describe("MarkdownBody", () => { expect(html).not.toContain("javascript:"); }); + it("uses the current company agent appearance for an old saved mention", () => { + mockUseOptionalCompany.mockReturnValue({ companies: [{ issuePrefix: "PAP" }], selectedCompanyId: "company-1" }); + const client = new QueryClient(); + client.setQueryData(queryKeys.agents.list("company-1"), [ + { id: "agent-123", appearance: appearanceForPalette("electric-grove") }, + ]); + const html = renderToStaticMarkup( + + + {`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")})`} + + , + ); + expect(html).toContain("/cap-v1/electric-grove/rest.png"); + expect(html).not.toContain("mention-icon-mask"); + }); + it("renders raw HTML tags as escaped text", () => { const html = renderMarkdown( '\n\n

Plain text

', diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index feed8630b80..6c4a9f11b45 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -10,6 +10,7 @@ import { useTheme } from "../context/ThemeContext"; import { useOptionalCompany } from "../context/CompanyContext"; import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips"; import { issuesApi } from "../api/issues"; +import { agentsApi } from "../api/agents"; import { getCachedIssueDetail } from "../lib/issueDetailCache"; import { queryKeys } from "../lib/queryKeys"; import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../lib/issue-reference"; @@ -41,10 +42,37 @@ import { import { normalizeExternalObjectHref } from "../lib/external-object-href"; import { copyTextToClipboard } from "../lib/clipboard"; import type { + Agent, ExternalObjectLivenessState, ExternalObjectStatusCategory, } from "@paperclipai/shared"; +function MarkdownAgentMention({ agentId, children, style }: { + agentId: string; + children: ReactNode; + style?: React.CSSProperties; +}) { + const companyId = useOptionalCompany()?.selectedCompanyId; + // All mentions share the company list cache; never fetch one agent per chip. + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(companyId ?? "__none__"), + queryFn: () => agentsApi.list(companyId!), + enabled: Boolean(companyId), + staleTime: 60_000, + }); + const appearance = agents?.find((agent) => agent.id === agentId)?.appearance; + return ( + + {children} + + ); +} + /** * Host-resolved external-object metadata for inline markdown decoration. * The renderer only consumes the host normalized fields here — plugin React @@ -870,6 +898,13 @@ function MarkdownBodyImpl({ const parsed = href ? parseMentionChipHref(href) : null; if (parsed) { + if (parsed.kind === "agent") { + return ( + + {linkChildren} + + ); + } const targetHref = parsed.kind === "project" ? `/projects/${parsed.projectId}` : parsed.kind === "issue" @@ -878,9 +913,7 @@ function MarkdownBodyImpl({ ? `/skills/${parsed.skillId}` : parsed.kind === "routine" ? `/routines/${parsed.routineId}` - : parsed.kind === "user" - ? "/company/settings/access" - : `/agents/${parsed.agentId}`; + : "/company/settings/access"; return ( { ).toEqual({ top: 100, left: 250, + maxWidth: 542, }); }); @@ -1019,6 +1020,7 @@ describe("MarkdownEditor", () => { ).toEqual({ top: 340, left: 154, + maxWidth: 182, }); }); @@ -1031,6 +1033,7 @@ describe("MarkdownEditor", () => { ).toEqual({ top: 12, left: 92, + maxWidth: 180, }); }); @@ -1043,6 +1046,7 @@ describe("MarkdownEditor", () => { ).toEqual({ top: 372, left: 210, + maxWidth: 582, }); }); @@ -1056,6 +1060,7 @@ describe("MarkdownEditor", () => { ).toEqual({ top: 160, left: 130, + maxWidth: 182, }); }); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 1b1190939ec..b7d260b26c3 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -492,7 +492,10 @@ export function computeMentionMenuPosition( const desiredLeft = viewport.offsetLeft + anchor.viewportLeft + MENTION_MENU_CARET_GAP; const left = Math.max(minLeft, Math.min(desiredLeft, maxLeft)); - return { top, left }; + // The menu can grow beyond its estimated width for long task names. + // Constrain its actual layout to the space remaining beside the caret. + const maxWidth = Math.max(0, viewport.offsetLeft + viewport.width - MENTION_MENU_PADDING - left); + return { top, left, maxWidth }; } function getMentionMenuSize(optionCount: number): MentionMenuSize { @@ -1030,7 +1033,7 @@ export const MarkdownEditor = forwardRef const option = mentionOptionByKey.get(`agent:${parsed.agentId}`); applyMentionChipDecoration(link, { ...parsed, - icon: parsed.icon ?? option?.agentIcon ?? null, + appearance: option?.agentAppearance, }); } }, [mentionOptionByKey]); @@ -1534,6 +1537,7 @@ export const MarkdownEditor = forwardRef style={{ top: mentionMenuPosition.top, left: mentionMenuPosition.left, + maxWidth: mentionMenuPosition.maxWidth, touchAction: "pan-y", WebkitOverflowScrolling: "touch", }} diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index 8e168c10f2c..b92e834bf30 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -6,6 +6,9 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { queryKeys } from "../lib/queryKeys"; +import { trackRecentAssignee, trackRecentAssigneeUser } from "../lib/recent-assignees"; +import { getLastProjectId, trackRecentProject } from "../lib/recent-projects"; +import { rememberComposerEffort } from "../lib/recent-composer-effort"; import { NewIssueDialog } from "./NewIssueDialog"; const dialogState = vi.hoisted(() => ({ @@ -46,6 +49,7 @@ const toastState = vi.hoisted(() => ({ const navigateMock = vi.hoisted(() => vi.fn()); const mockIssuesApi = vi.hoisted(() => ({ + list: vi.fn(), create: vi.fn(), upsertDocument: vi.fn(), uploadAttachment: vi.fn(), @@ -82,6 +86,8 @@ vi.mock("../context/DialogContext", () => ({ useDialog: () => dialogState, })); +vi.mock("../context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: false }) })); + vi.mock("../context/CompanyContext", () => ({ useCompany: () => companyState, })); @@ -143,10 +149,8 @@ vi.mock("../hooks/useProjectOrder", () => ({ }), })); -vi.mock("../lib/recent-assignees", () => ({ - getRecentAssigneeIds: () => [], - sortAgentsByRecency: (agents: unknown[]) => agents, - trackRecentAssignee: vi.fn(), +vi.mock("../api/access", () => ({ + accessApi: { listUserDirectory: async () => ({ users: [] }) }, })); vi.mock("../lib/assignees", () => ({ @@ -157,7 +161,7 @@ vi.mock("../lib/assignees", () => ({ assigneeAgentId?: string; assigneeUserId?: string; }) => assigneeAgentId ? `agent:${assigneeAgentId}` : assigneeUserId ? `user:${assigneeUserId}` : "", - currentUserAssigneeOption: () => [], + currentUserAssigneeOption: (id: string | null) => id ? [{ id: `user:${id}`, label: "Me" }] : [], parseAssigneeValue: (value: string) => ({ assigneeAgentId: value.startsWith("agent:") ? value.slice("agent:".length) : null, assigneeUserId: value.startsWith("user:") ? value.slice("user:".length) : null, @@ -195,11 +199,13 @@ vi.mock("./InlineEntitySelector", async () => { placeholder?: string; className?: string; triggerDataSlot?: string; + options?: { id: string; label: string }[]; + onChange?: (id: string) => void; renderTriggerValue?: (option: { id: string; label: string } | null) => ReactNode; } - >(function InlineEntitySelectorMock({ value, placeholder, className, triggerDataSlot, renderTriggerValue }, ref) { + >(function InlineEntitySelectorMock({ value, placeholder, className, triggerDataSlot, renderTriggerValue, options = [], onChange }, ref) { return ( - ); @@ -212,6 +218,7 @@ vi.mock("./AgentIconPicker", () => ({ })); vi.mock("@/components/ui/dialog", () => ({ + DialogTitle: ({ children, ...props }: ComponentProps<"h2">) =>

{children}

, Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ?
{children}
: null), DialogContent: ({ children, @@ -242,9 +249,17 @@ vi.mock("@/components/ui/toggle-switch", () => ({ ), })); +vi.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children, onSelect, ...props }: Omit, "onSelect"> & { onSelect?: () => void }) => , +})); + vi.mock("@/components/ui/popover", () => ({ Popover: ({ children }: { children: ReactNode }) =>
{children}
, PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}, + PopoverAnchor: ({ children }: { children: ReactNode }) => <>{children}, PopoverContent: ({ children, disablePortal }: { children: ReactNode; disablePortal?: boolean }) => (
{children}
), @@ -269,10 +284,10 @@ async function flush() { }); } -async function typeTextareaValue(textarea: HTMLTextAreaElement, value: string) { +async function typeTextareaValue(textarea: HTMLTextAreaElement | HTMLInputElement, value: string) { await act(async () => { const valueSetter = Object.getOwnPropertyDescriptor( - window.HTMLTextAreaElement.prototype, + textarea instanceof HTMLInputElement ? window.HTMLInputElement.prototype : window.HTMLTextAreaElement.prototype, "value", )?.set; valueSetter?.call(textarea, value); @@ -349,6 +364,7 @@ describe("NewIssueDialog", () => { toastState.pushToast.mockReset(); navigateMock.mockReset(); mockIssuesApi.create.mockReset(); + mockIssuesApi.list.mockReset().mockResolvedValue([]); mockIssuesApi.upsertDocument.mockReset(); mockIssuesApi.uploadAttachment.mockReset(); mockExecutionWorkspacesApi.list.mockReset(); @@ -392,6 +408,142 @@ describe("NewIssueDialog", () => { document.body.innerHTML = ""; }); + const defaultAgents = [ + { id: "worker", name: "Worker", role: "engineer", status: "idle", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} }, + { id: "ceo", name: "CEO", role: "ceo", status: "idle", adapterType: "claude_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} }, + ]; + + it.each([ + { remembered: "project-1", expected: "project-1" }, + { remembered: "", expected: "Project" }, + { remembered: "deleted", expected: "Project" }, + ])("restores the last available project ($remembered)", async ({ remembered, expected }) => { + trackRecentProject(remembered, "company-1"); + const { root } = renderDialog(container); + await waitForAssertion(() => expect(container.querySelector('[data-slot="new-issue-compact-control"]')?.textContent).toContain(expected)); + await typeTextareaValue(container.querySelector('textarea[aria-label="Describe a task…"]')!, "Check project memory"); + act(() => container.querySelector('[aria-label="Create task"]')!.click()); + await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled()); + expect(mockIssuesApi.create.mock.calls[0][1].projectId).toBe(remembered === "project-1" ? "project-1" : undefined); + act(() => root.unmount()); + }); + + it("keeps explicit launch context ahead of another company's project history", async () => { + trackRecentProject("project-1", "company-2"); + trackRecentProject("deleted", "company-1"); + dialogState.newIssueDefaults = { projectId: "project-1" }; + const { root } = renderDialog(container); + await waitForAssertion(() => expect(container.querySelector('[data-slot="new-issue-compact-control"]')?.textContent).toContain("project-1")); + act(() => root.unmount()); + dialogState.newIssueDefaults = {}; + const next = renderDialog(container); + await flush(); + expect(container.querySelector('[data-slot="new-issue-compact-control"]')?.textContent).toContain("Project"); + act(() => next.root.unmount()); + }); + + it("remembers a project selection even when the composer is dismissed without submitting", async () => { + const { root } = renderDialog(container); + await flush(); + act(() => container.querySelector('[data-slot="new-issue-compact-control"]')!.click()); + expect(getLastProjectId("company-1")).toBe("project-1"); + act(() => root.unmount()); + const next = renderDialog(container); + await waitForAssertion(() => expect(container.querySelector('[data-slot="new-issue-compact-control"]')?.textContent).toContain("project-1")); + act(() => next.root.unmount()); + }); + + it("submits remembered effort with the agent's default model", async () => { + mockAgentsApi.list.mockResolvedValue(defaultAgents.map((agent) => agent.id === "ceo" + ? { ...agent, adapterConfig: { model: "claude-opus-5" } } : agent)); + mockAgentsApi.adapterModels.mockResolvedValue([{ id: "claude-opus-5", label: "Claude Opus 5" }]); + rememberComposerEffort("company-1", "high"); + const { root } = renderDialog(container); + await waitForAssertion(() => expect(container.querySelector('[data-testid="task-chat-composer-model-label"]')?.textContent).toBe("Claude Opus 5")); + await typeTextareaValue(container.querySelector('textarea[aria-label="Describe a task…"]')!, "Check effort memory"); + act(() => container.querySelector('[aria-label="Create task"]')!.click()); + await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled()); + expect(mockIssuesApi.create.mock.calls[0][1].assigneeAdapterOverrides).toEqual({ adapterConfig: { effort: "high" } }); + act(() => root.unmount()); + }); + + it.each([ + { recent: "", expected: "CEO" }, + { recent: "agent:worker", expected: "Worker" }, + { recent: "user:user-1", expected: "Me" }, + { recent: "agent:deleted", expected: "CEO" }, + ])("defaults to $expected for recent selection $recent without inserting a mention", async ({ recent, expected }) => { + mockAgentsApi.list.mockResolvedValue(defaultAgents); + if (recent.startsWith("agent:")) trackRecentAssignee(recent.slice(6), "company-1"); + if (recent.startsWith("user:")) trackRecentAssigneeUser(recent.slice(5), "company-1"); + const { root } = renderDialog(container); + await waitForAssertion(() => expect(container.querySelector('[data-testid="task-chat-composer-assignee-label"]')?.textContent).toBe(expected)); + expect(container.querySelector('textarea[aria-label="Describe a task…"]')?.value).toBe(""); + expect(container.textContent).not.toContain("@CEO"); + act(() => root.unmount()); + }); + + it("ignores another company's remembered human and keeps explicit launch assignees", async () => { + mockAgentsApi.list.mockResolvedValue(defaultAgents); + trackRecentAssigneeUser("user-1", "company-2"); + dialogState.newIssueDefaults = { assigneeAgentId: "worker" }; + const { root } = renderDialog(container); + await waitForAssertion(() => expect(container.querySelector('[data-testid="task-chat-composer-assignee-label"]')?.textContent).toBe("Worker")); + act(() => root.unmount()); + dialogState.newIssueDefaults = {}; + const next = renderDialog(container); + await waitForAssertion(() => expect(container.querySelector('[data-testid="task-chat-composer-assignee-label"]')?.textContent).toBe("CEO")); + act(() => next.root.unmount()); + }); + + it("uses older agent history only when the agent belongs to the current company", async () => { + mockAgentsApi.list.mockResolvedValue(defaultAgents); + trackRecentAssignee("worker"); + trackRecentAssignee("another-company-agent"); + const { root } = renderDialog(container); + await waitForAssertion(() => expect(container.querySelector('[data-testid="task-chat-composer-assignee-label"]')?.textContent).toBe("Worker")); + act(() => root.unmount()); + }); + + it("remembers a chosen human and leaves the request empty", async () => { + mockAgentsApi.list.mockResolvedValue(defaultAgents); + const { root } = renderDialog(container); + await waitForAssertion(() => expect(container.querySelector('[data-testid="task-chat-composer-assignee-label"]')?.textContent).toBe("CEO")); + act(() => container.querySelector('[aria-label="Select assignee"]')!.click()); + const me = [...container.querySelectorAll('[role="option"]')].find((button) => button.textContent?.includes("Me"))!; + act(() => me.click()); + expect(localStorage.getItem("paperclip:recent-assignees:company-1")).toContain("user:user-1"); + expect(container.querySelector('[data-testid="task-chat-composer-model-label"]')).toBeNull(); + expect(container.querySelector('textarea[aria-label="Describe a task…"]')?.value).toBe(""); + act(() => root.unmount()); + }); + + it("applies the default after agents load without replacing a request typed meanwhile", async () => { + let resolveAgents!: (agents: typeof defaultAgents) => void; + mockAgentsApi.list.mockReturnValue(new Promise((resolve) => { resolveAgents = resolve; })); + const { root } = renderDialog(container); + await flush(); + await typeTextareaValue(container.querySelector('textarea[aria-label="Describe a task…"]')!, "hello there"); + resolveAgents(defaultAgents); + await waitForAssertion(() => expect(container.querySelector('[data-testid="task-chat-composer-assignee-label"]')?.textContent).toBe("CEO")); + expect(container.querySelector('textarea[aria-label="Describe a task…"]')?.value).toBe("hello there"); + act(() => root.unmount()); + }); + + it("keeps an explicitly chosen human while agents are still loading", async () => { + let resolveAgents!: (agents: typeof defaultAgents) => void; + mockAgentsApi.list.mockReturnValue(new Promise((resolve) => { resolveAgents = resolve; })); + const { root } = renderDialog(container); + await flush(); + act(() => container.querySelector('[aria-label="Select assignee"]')!.click()); + const me = [...container.querySelectorAll('[role="option"]')].find((button) => button.textContent?.includes("Me"))!; + act(() => me.click()); + resolveAgents(defaultAgents); + await flush(); + expect(container.querySelector('[data-testid="task-chat-composer-assignee-label"]')?.textContent).toBe("Me"); + act(() => root.unmount()); + }); + it("shows sub-issue context only when opened from a sub-issue action", async () => { dialogState.newIssueDefaults = { parentId: "issue-1", @@ -408,7 +560,7 @@ describe("NewIssueDialog", () => { expect(container.textContent).toContain("Sub-task of"); expect(container.textContent).toContain("PAP-1"); expect(container.textContent).toContain("Parent issue"); - expect(container.textContent).toContain("Create Sub-Task"); + expect(container.querySelector('[aria-label="Create sub-task"]')).not.toBeNull(); act(() => root.unmount()); @@ -417,34 +569,32 @@ describe("NewIssueDialog", () => { await flush(); expect(container.textContent).toContain("New task"); - expect(container.textContent).toContain("Create Task"); + expect(container.querySelector('[aria-label="Create task"]')).not.toBeNull(); expect(container.textContent).not.toContain("Sub-task of"); act(() => rerendered.root.unmount()); }); - it("uses the compact composer control proportions for mobile task fields", async () => { + it("uses the task chat composer for the editor and send control", async () => { + mockAgentsApi.list.mockResolvedValue([{ id: "agent-1", name: "Coder", status: "active", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} }]); const { root } = renderDialog(container); await flush(); - - const compactControls = Array.from( - container.querySelectorAll('[data-slot="new-issue-compact-control"]'), - ); - const prefix = compactControls.find((control) => control.textContent === "PAP"); - const assignee = compactControls.find((control) => control.textContent === "Assignee"); - const project = compactControls.find((control) => control.textContent === "Project"); - const status = compactControls.find((control) => control.textContent?.trim() === "Todo"); - const upload = compactControls.find((control) => control.textContent?.trim() === "Upload"); - const mode = compactControls.find((control) => control.hasAttribute("data-issue-work-mode-chip")); - const more = container.querySelector('[data-testid="new-issue-more-menu-trigger"]'); - - expect(prefix?.className).toContain("p-1.5"); - for (const control of [assignee, project, status, upload, mode]) { - expect(control?.className).toContain("h-8"); - expect(control?.className).toContain("px-2.5"); - } - expect(more?.className).toContain("size-8"); - + const composer = container.querySelector(".paperclip-task-chat-composer"); + expect(composer).not.toBeNull(); + expect(composer?.querySelector('[data-testid="task-chat-composer-input"]')).not.toBeNull(); + expect(composer?.querySelector('[aria-label="Create task"]')).not.toBeNull(); + expect(composer?.querySelector('[aria-label="Task settings"]')).toBeNull(); + expect(container.querySelector("h2")?.className).toBe("sr-only"); + expect(composer?.textContent).not.toContain("PAP"); + await waitForAssertion(() => expect(composer?.querySelector('[data-testid="task-chat-composer-assignee"]')).not.toBeNull()); + const toolbar = composer?.querySelector('[data-testid="task-chat-composer-actions"]'); + const context = container.querySelector('[data-testid="task-chat-composer-context"]'); + const project = context?.querySelector('[data-slot="new-issue-compact-control"]'); + const assignee = toolbar?.querySelector('[data-testid="task-chat-composer-assignee"]'); + expect(project).not.toBeNull(); + expect(context?.nextElementSibling).toBe(composer); + expect(assignee).not.toBeNull(); + expect(toolbar?.querySelector('[data-slot="new-issue-compact-control"]')).toBeNull(); act(() => root.unmount()); }); @@ -491,14 +641,13 @@ describe("NewIssueDialog", () => { await waitForAssertion(() => { expect(mockExecutionWorkspacesApi.listSummaries).toHaveBeenCalledWith("company-1", { projectId: "project-1", - projectWorkspaceId: undefined, reuseEligible: true, }); }); expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled(); const submitButton = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Create Sub-Task")); + .find((button) => button.getAttribute("aria-label") === "Create sub-task"); expect(submitButton).not.toBeUndefined(); await waitForAssertion(() => { expect(submitButton?.hasAttribute("disabled")).toBe(false); @@ -601,26 +750,10 @@ describe("NewIssueDialog", () => { const { root } = renderDialog(container); await waitForAssertion(() => { - expect(container.textContent).toContain("Codex options"); + expect(container.querySelector('[aria-label="Select model and effort"]')).not.toBeNull(); + expect(container.querySelector('[aria-label="Effort"]')?.getAttribute("max")).toBe("6"); }); - - const codexOptionsButton = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Codex options")); - expect(codexOptionsButton).not.toBeUndefined(); - await act(async () => { - codexOptionsButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - - const customLane = Array.from(container.querySelectorAll('button[role="radio"]')) - .find((button) => button.textContent?.trim() === "Custom"); - expect(customLane).not.toBeUndefined(); - await act(async () => { - customLane!.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - - expect(container.textContent).toContain("Ultra"); - expect(container.textContent).toContain("Max"); - expect(container.textContent).not.toContain("Minimal"); + expect(container.textContent).not.toContain("Codex options"); act(() => root.unmount()); }); @@ -661,11 +794,11 @@ describe("NewIssueDialog", () => { const { root } = renderDialog(container); await flush(); - const planningButton = container.querySelector('[data-issue-work-mode="planning"]'); - expect(planningButton?.className).toContain("bg-accent"); + const planningButton = container.querySelector('[data-testid="composer-add-plan"]'); + expect(container.querySelector("[data-pending-work-mode=planning]")).not.toBeNull(); const submitButton = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Create Task")); + .find((button) => button.getAttribute("aria-label") === "Create task"); expect(submitButton).not.toBeUndefined(); await vi.waitFor(() => { expect(submitButton?.hasAttribute("disabled")).toBe(false); @@ -696,11 +829,11 @@ describe("NewIssueDialog", () => { const { root } = renderDialog(container); await flush(); - const askButton = container.querySelector('[data-issue-work-mode="ask"]'); - expect(askButton?.className).toContain("bg-accent"); + const askButton = container.querySelector('[data-testid="composer-add-ask"]'); + expect(container.querySelector("[data-pending-work-mode=ask]")).not.toBeNull(); const submitButton = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Create Task")); + .find((button) => button.getAttribute("aria-label") === "Create task"); expect(submitButton).not.toBeUndefined(); await vi.waitFor(() => { expect(submitButton?.hasAttribute("disabled")).toBe(false); @@ -738,8 +871,8 @@ describe("NewIssueDialog", () => { await flush(); expect(container.textContent).not.toContain("Execution workspace"); expect(container.querySelector('option[value="isolated_workspace"]')).toBeNull(); - await typeTextareaValue(container.querySelector('textarea[placeholder="Task title (optional)"]')!, "Managed task"); - const create = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Create Task")); + await typeTextareaValue(container.querySelector('textarea[aria-label="Describe a task…"]')!, "Managed task"); + const create = Array.from(container.querySelectorAll("button")).find((button) => button.getAttribute("aria-label") === "Create task"); act(() => create!.click()); await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled()); const payload = mockIssuesApi.create.mock.calls[0][1]; @@ -758,8 +891,8 @@ describe("NewIssueDialog", () => { const { root } = renderDialog(container, ["workspaces.isolation"]); await flush(); expect(container.querySelector('option[value="isolated_workspace"]')).toBeNull(); - await typeTextareaValue(container.querySelector('textarea[placeholder="Task title (optional)"]')!, "Context task"); - const create = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes(subtask ? "Create Sub-Task" : "Create Task")); + await typeTextareaValue(container.querySelector('textarea[aria-label="Describe a task…"]')!, "Context task"); + const create = Array.from(container.querySelectorAll("button")).find((button) => button.getAttribute("aria-label") === (subtask ? "Create sub-task" : "Create task")); act(() => create!.click()); await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled()); expect(mockIssuesApi.create.mock.calls[0][1]).toMatchObject({ @@ -814,17 +947,20 @@ describe("NewIssueDialog", () => { executionWorkspaceId: "workspace-1", }; - const { root } = renderDialog(container); + const { root, queryClient } = renderDialog(container); await flush(); + await waitForAssertion(() => { + expect(queryClient.getQueryData(queryKeys.executionWorkspaces.summaryList("company-1", { + projectId: "project-1", reuseEligible: true, + }))).toEqual(expect.arrayContaining([expect.objectContaining({ id: "workspace-1" })])); + }); + await flush(); expect(container.textContent).toContain("New task"); expect(container.textContent).not.toContain("New sub-task"); - await waitForAssertion(() => { - expect(container.textContent).toContain("Reusing PAP-100"); - }); const submitButton = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Create Task")); + .find((button) => button.getAttribute("aria-label") === "Create task"); expect(submitButton).not.toBeUndefined(); await act(async () => { @@ -849,57 +985,130 @@ describe("NewIssueDialog", () => { act(() => root.unmount()); }); - it("keeps the reusable workspace search popover inside the modal", async () => { - mockProjectsApi.list.mockResolvedValue([ - { - id: "project-1", - name: "Alpha", - description: null, - archivedAt: null, - color: "#445566", - workspaces: [ - { - id: "project-workspace-1", - name: "Primary", - isPrimary: true, - }, - ], - executionWorkspacePolicy: { - enabled: true, - defaultMode: "shared_workspace", - }, - }, - ]); - mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([ - { - id: "workspace-1", - name: "PAP-11446-on-mobile-the-agent-chat", - mode: "isolated_workspace", - status: "active", - branchName: "PAP-11446-on-mobile-the-agent-chat", - cwd: "/tmp/workspace-1", - projectWorkspaceId: "project-workspace-1", - lastUsedAt: new Date("2026-04-06T16:00:00.000Z"), - }, - ]); + function enableWorktrees(mode = "shared_workspace") { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); - dialogState.newIssueDefaults = { - title: "Follow-up issue", - projectId: "project-1", - executionWorkspaceId: "workspace-1", - }; + mockProjectsApi.list.mockResolvedValue([ + { id: "project-1", name: "Alpha", executionWorkspacePolicy: { enabled: true, defaultMode: mode }, workspaces: [{ id: "source-1", isPrimary: true }] }, + { id: "project-2", name: "Beta", executionWorkspacePolicy: { enabled: false }, workspaces: [] }, + ]); + dialogState.newIssueDefaults = { projectId: "project-1", description: "Fix sign-in" }; + } + function selectWorktree(value: string) { + const option = container.querySelector(`[cmdk-item][data-value="${value}"]`); + expect(option).not.toBeNull(); + act(() => option!.click()); + } + + it.each(["isolated_workspace", "operator_branch", "adapter_managed"])("reuses a worktree with its actual mode (%s)", async (mode) => { + enableWorktrees(); + mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([{ + id: "worktree-1", name: "Sign-in fix", mode, status: "active", branchName: "fix/sign-in", + cwd: "/tmp/worktree-1", projectWorkspaceId: "source-1", lastUsedAt: new Date(), + }]); const { root } = renderDialog(container); - await flush(); - - await waitForAssertion(() => { - const workspaceInput = container.querySelector('input[placeholder="Search workspaces..."]'); - expect(workspaceInput?.closest("[data-disable-portal]")?.getAttribute("data-disable-portal")).toBe("true"); + await waitForAssertion(() => expect(container.querySelector('[cmdk-item][data-value="reuse:worktree-1"]')).not.toBeNull()); + const context = container.querySelector('[data-testid="task-chat-composer-context"]'); + const project = context?.querySelector('[data-slot="new-issue-compact-control"]'); + const worktrees = context?.querySelector('[aria-label="Worktrees"]'); + expect(worktrees).not.toBeNull(); + expect(project?.compareDocumentPosition(worktrees!)! & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + selectWorktree("reuse:worktree-1"); + act(() => container.querySelector('[aria-label="Create task"]')!.click()); + await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled()); + expect(mockIssuesApi.create.mock.calls[0][1]).toMatchObject({ + projectId: "project-1", projectWorkspaceId: "source-1", executionWorkspaceId: "worktree-1", + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { mode: mode === "adapter_managed" ? "agent_default" : mode }, }); - act(() => root.unmount()); }); + it.each(["isolated_workspace", "shared_workspace"])("restores the project checkout and clears reuse when choosing %s", async (mode) => { + enableWorktrees(); + mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([{ + id: "worktree-1", name: "Sign-in fix", mode: "isolated_workspace", status: "idle", + branchName: "fix/sign-in", cwd: "/tmp/worktree-1", projectWorkspaceId: "source-2", lastUsedAt: new Date(), + }]); + const { root } = renderDialog(container); + await waitForAssertion(() => expect(container.querySelector('[cmdk-item][data-value="reuse:worktree-1"]')).not.toBeNull()); + selectWorktree("reuse:worktree-1"); + selectWorktree(mode); + act(() => container.querySelector('[aria-label="Create task"]')!.click()); + await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled()); + const payload = mockIssuesApi.create.mock.calls[0][1]; + expect(payload).toMatchObject({ projectWorkspaceId: "source-1", executionWorkspacePreference: mode, executionWorkspaceSettings: { mode } }); + expect(payload).not.toHaveProperty("executionWorkspaceId"); + act(() => root.unmount()); + }); + + it.each([false, true])("carries the source checkout when reusing another worktree in the same project (inherited: %s)", async (inherited) => { + enableWorktrees(); + if (inherited) dialogState.newIssueDefaults.executionWorkspaceId = "worktree-secondary"; + mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([{ + id: "worktree-secondary", name: "Secondary checkout fix", mode: "isolated_workspace", status: "active", + branchName: "fix/secondary", cwd: "/tmp/worktree-secondary", projectWorkspaceId: "source-2", lastUsedAt: new Date(), + }]); + const { root } = renderDialog(container); + await waitForAssertion(() => expect(container.querySelector('[cmdk-item][data-value="reuse:worktree-secondary"]')).not.toBeNull()); + expect(mockExecutionWorkspacesApi.listSummaries).toHaveBeenCalledWith("company-1", { projectId: "project-1", reuseEligible: true }); + if (!inherited) selectWorktree("reuse:worktree-secondary"); + act(() => container.querySelector('[aria-label="Create task"]')!.click()); + await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled()); + expect(mockIssuesApi.create.mock.calls[0][1]).toMatchObject({ projectWorkspaceId: "source-2", executionWorkspaceId: "worktree-secondary" }); + act(() => root.unmount()); + }); + + it("clears a reused worktree when switching to a project without isolation", async () => { + enableWorktrees(); + dialogState.newIssueDefaults.executionWorkspaceId = "worktree-1"; + const { root } = renderDialog(container); + await flush(); + act(() => container.querySelector('[data-slot="new-issue-compact-control"]')!.click()); + await flush(); + expect(container.querySelector('[aria-label="Worktrees"]')).toBeNull(); + act(() => container.querySelector('[aria-label="Create task"]')!.click()); + await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled()); + const payload = mockIssuesApi.create.mock.calls[0][1]; + expect(payload.projectId).toBe("project-2"); + expect(payload).not.toHaveProperty("executionWorkspaceId"); + expect(payload).not.toHaveProperty("executionWorkspacePreference"); + expect(payload).not.toHaveProperty("executionWorkspaceSettings"); + act(() => root.unmount()); + }); + + it.each([false, true])("hides worktrees and skips reuse queries when the project has isolation disabled (instance: %s)", async (instanceEnabled) => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: instanceEnabled }); + dialogState.newIssueDefaults = { projectId: "project-1" }; + const { root } = renderDialog(container); + await flush(); + expect(container.querySelector('[aria-label="Worktrees"]')).toBeNull(); + expect(mockExecutionWorkspacesApi.listSummaries).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it.each(["loading", "error", "missing"])("blocks an unchecked reused worktree but still permits a new one (%s)", async (state) => { + enableWorktrees(); + dialogState.newIssueDefaults.executionWorkspaceId = "stale-worktree"; + if (state === "loading") mockExecutionWorkspacesApi.listSummaries.mockImplementation(() => new Promise(() => {})); + if (state === "error") mockExecutionWorkspacesApi.listSummaries.mockRejectedValue(new Error("Unavailable")); + const { root } = renderDialog(container); + await waitForAssertion(() => expect(container.querySelector('[aria-label="Worktrees"]')).not.toBeNull()); + await flush(); + const submit = container.querySelector('[aria-label="Create task"]')!; + expect(submit.disabled).toBe(true); + act(() => { container.querySelector('textarea')!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true })); }); + expect(mockIssuesApi.create).not.toHaveBeenCalled(); + selectWorktree("isolated_workspace"); + await flush(); + expect(submit.disabled).toBe(false); + act(() => submit.click()); + await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled()); + expect(mockIssuesApi.create.mock.calls[0][1]).toMatchObject({ executionWorkspacePreference: "isolated_workspace" }); + act(() => root.unmount()); + }); + + it("restores a description-only draft", async () => { localStorage.setItem("paperclip:issue-draft", JSON.stringify({ title: "", description: "Keep the request without a title", status: "todo", priority: "medium", @@ -909,9 +1118,9 @@ describe("NewIssueDialog", () => { const { root } = renderDialog(container); await flush(); await waitForAssertion(() => { - expect((container.querySelector('textarea[aria-label="Add description..."]') as HTMLTextAreaElement).value).toBe("Keep the request without a title"); + expect((container.querySelector('textarea[aria-label="Describe a task…"]') as HTMLTextAreaElement).value).toBe("Keep the request without a title"); }); - const submit = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Create Task"))!; + const submit = Array.from(container.querySelectorAll("button")).find(button => button.getAttribute("aria-label") === "Create task")!; expect(submit.hasAttribute("disabled")).toBe(false); await act(async () => root.unmount()); }); @@ -919,9 +1128,9 @@ describe("NewIssueDialog", () => { it("creates a task from its description without requiring a title", async () => { const { root } = renderDialog(container); await flush(); - const submit = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Create Task"))!; + const submit = Array.from(container.querySelectorAll("button")).find(button => button.getAttribute("aria-label") === "Create task")!; expect(submit.hasAttribute("disabled")).toBe(true); - await typeTextareaValue(container.querySelector('textarea[aria-label="Add description..."]')!, "Investigate the sign-in redirect and fix it"); + await typeTextareaValue(container.querySelector('textarea[aria-label="Describe a task…"]')!, "Investigate the sign-in redirect and fix it"); await vi.waitFor(() => expect(submit.hasAttribute("disabled")).toBe(false)); await act(async () => { submit.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); @@ -930,7 +1139,25 @@ describe("NewIssueDialog", () => { await act(async () => root.unmount()); }); - it("submits the latest locally typed title and description", async () => { + it.each(["Corrected task title", ""])("lets users edit or clear an inherited title (%s)", async (nextTitle) => { + dialogState.newIssueDefaults = { title: "Suggested title", description: "Investigate the redirect" }; + const { root } = renderDialog(container); + await flush(); + const titleInput = container.querySelector('input[aria-label="Task title"]')!; + expect(titleInput.value).toBe("Suggested title"); + await typeTextareaValue(titleInput, nextTitle); + expect(container.querySelector('input[aria-label="Task title"]')).toBe(titleInput); + const submit = container.querySelector('[aria-label="Create task"]')!; + await act(async () => submit.click()); + await flush(); + const payload = mockIssuesApi.create.mock.calls[0][1]; + expect(payload.description).toBe("Investigate the redirect"); + if (nextTitle) expect(payload.title).toBe(nextTitle); + else expect(payload).not.toHaveProperty("title"); + await act(async () => root.unmount()); + }); + + it("keeps a typed request when project data arrives after editing starts", async () => { let resolveProjects: (projects: Array<{ id: string; name: string; @@ -942,15 +1169,13 @@ describe("NewIssueDialog", () => { resolveProjects = resolve; })); + dialogState.newIssueDefaults = { title: "Typed issue" }; const { root } = renderDialog(container); await flush(); - const titleInput = container.querySelector('textarea[placeholder="Task title (optional)"]') as HTMLTextAreaElement | null; - const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]') as HTMLTextAreaElement | null; - expect(titleInput).not.toBeNull(); + const descriptionInput = container.querySelector('textarea[aria-label="Describe a task…"]') as HTMLTextAreaElement | null; expect(descriptionInput).not.toBeNull(); - await typeTextareaValue(titleInput!, "Typed issue"); await typeTextareaValue(descriptionInput!, "Typed description"); await act(async () => { @@ -968,7 +1193,7 @@ describe("NewIssueDialog", () => { await flush(); const submitButton = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Create Task")); + .find((button) => button.getAttribute("aria-label") === "Create task"); expect(submitButton).not.toBeUndefined(); await vi.waitFor(() => { expect(submitButton?.hasAttribute("disabled")).toBe(false); @@ -999,7 +1224,7 @@ describe("NewIssueDialog", () => { await flush(); const submitButton = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Create Task")); + .find((button) => button.getAttribute("aria-label") === "Create task"); expect(submitButton).not.toBeUndefined(); await act(async () => { @@ -1007,7 +1232,7 @@ describe("NewIssueDialog", () => { }); await flush(); - expect(submitButton?.textContent).toContain("Creating..."); + expect(submitButton?.querySelector(".animate-spin")).not.toBeNull(); expect(submitButton?.getAttribute("aria-busy")).toBe("true"); expect(container.textContent).not.toContain("Creating issue"); @@ -1019,7 +1244,7 @@ describe("NewIssueDialog", () => { const { root, queryClient } = renderDialog(container); await flush(); const submit = () => Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Create Task"))!; + .find((button) => button.getAttribute("aria-label") === "Create task")!; act(() => submit().click()); await waitForAssertion(() => expect(dialogState.closeNewIssue).toHaveBeenCalledTimes(1)); @@ -1057,7 +1282,7 @@ describe("NewIssueDialog", () => { const { root } = renderDialog(container); await flush(); act(() => Array.from(container.querySelectorAll("button")) - .find(button => button.textContent?.includes("Create Task"))!.click()); + .find(button => button.getAttribute("aria-label") === "Create task")!.click()); await waitForAssertion(() => expect(dialogState.closeNewIssue).toHaveBeenCalledTimes(1)); expect(mockIssuesApi.create.mock.calls[0][1].idempotencyKey) .toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); @@ -1072,7 +1297,7 @@ describe("NewIssueDialog", () => { const { root } = renderDialog(container); await flush(); const submit = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Create Task"))!; + .find((button) => button.getAttribute("aria-label") === "Create task")!; act(() => submit.click()); await waitForAssertion(() => expect(dialogState.closeNewIssue).toHaveBeenCalledTimes(1)); expect(mockIssuesApi.create.mock.calls[0][1]).not.toHaveProperty("navigateOnCreate"); @@ -1091,7 +1316,7 @@ describe("NewIssueDialog", () => { const { root } = renderDialog(container); await flush(); act(() => Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Create Task"))!.click()); + .find((button) => button.getAttribute("aria-label") === "Create task")!.click()); await waitForAssertion(() => expect(navigateMock).toHaveBeenCalledExactlyOnceWith("/PAP/issues/created-task")); act(() => root.unmount()); }); @@ -1102,7 +1327,7 @@ describe("NewIssueDialog", () => { const { root } = renderDialog(container); await flush(); const submit = () => Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Create Task"))!; + .find((button) => button.getAttribute("aria-label") === "Create task")!; act(() => submit().click()); await waitForAssertion(() => expect(container.textContent).toContain("Request failed")); @@ -1116,7 +1341,7 @@ describe("NewIssueDialog", () => { await flush(); expect(mockIssuesApi.create.mock.calls[1][1].idempotencyKey).toBe(firstPayload.idempotencyKey); - await typeTextareaValue(container.querySelector('textarea[placeholder="Task title (optional)"]')!, "Connect another app"); + await typeTextareaValue(container.querySelector('input[aria-label="Task title"]')!, "Connect another app"); act(() => submit().click()); await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalledTimes(3)); expect(mockIssuesApi.create.mock.calls[2][1].idempotencyKey).not.toBe(firstPayload.idempotencyKey); @@ -1131,19 +1356,17 @@ describe("NewIssueDialog", () => { "हिन्दी: कृपया स्थिति बताएं।", ].join("\n"); + dialogState.newIssueDefaults = { title }; const { root } = renderDialog(container); await flush(); - const titleInput = container.querySelector('textarea[placeholder="Task title (optional)"]') as HTMLTextAreaElement | null; - const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]') as HTMLTextAreaElement | null; - expect(titleInput).not.toBeNull(); + const descriptionInput = container.querySelector('textarea[aria-label="Describe a task…"]') as HTMLTextAreaElement | null; expect(descriptionInput).not.toBeNull(); - await typeTextareaValue(titleInput!, title); await typeTextareaValue(descriptionInput!, description); const submitButton = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Create Task")); + .find((button) => button.getAttribute("aria-label") === "Create task"); expect(submitButton).not.toBeUndefined(); await vi.waitFor(() => { expect(submitButton?.hasAttribute("disabled")).toBe(false); @@ -1170,11 +1393,11 @@ describe("NewIssueDialog", () => { const { root } = renderDialog(container); await flush(); - const titleInput = container.querySelector('textarea[placeholder="Task title (optional)"]') as HTMLTextAreaElement | null; + const titleInput = container.querySelector('textarea[aria-label="Describe a task…"]') as HTMLTextAreaElement | null; expect(titleInput).not.toBeNull(); await typeTextareaValue(titleInput!, "Plan this first"); - const planningButton = container.querySelector('[data-issue-work-mode="planning"]'); + const planningButton = container.querySelector('[data-testid="composer-add-plan"]'); expect(planningButton).not.toBeNull(); await act(async () => { planningButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -1182,7 +1405,7 @@ describe("NewIssueDialog", () => { await flush(); const submitButton = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Create Task")); + .find((button) => button.getAttribute("aria-label") === "Create task"); expect(submitButton).not.toBeUndefined(); await vi.waitFor(() => { expect(submitButton?.hasAttribute("disabled")).toBe(false); @@ -1196,7 +1419,7 @@ describe("NewIssueDialog", () => { expect(mockIssuesApi.create).toHaveBeenCalledWith( "company-1", expect.objectContaining({ - title: "Plan this first", + description: "Plan this first", workMode: "planning", }), ); @@ -1208,11 +1431,11 @@ describe("NewIssueDialog", () => { const { root } = renderDialog(container); await flush(); - const titleInput = container.querySelector('textarea[placeholder="Task title (optional)"]') as HTMLTextAreaElement | null; + const titleInput = container.querySelector('textarea[aria-label="Describe a task…"]') as HTMLTextAreaElement | null; expect(titleInput).not.toBeNull(); await typeTextareaValue(titleInput!, "Answer this first"); - const askButton = container.querySelector('[data-issue-work-mode="ask"]'); + const askButton = container.querySelector('[data-testid="composer-add-ask"]'); expect(askButton).not.toBeNull(); await act(async () => { askButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -1220,7 +1443,7 @@ describe("NewIssueDialog", () => { await flush(); const submitButton = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Create Task")); + .find((button) => button.getAttribute("aria-label") === "Create task"); expect(submitButton).not.toBeUndefined(); await vi.waitFor(() => { expect(submitButton?.hasAttribute("disabled")).toBe(false); @@ -1234,7 +1457,7 @@ describe("NewIssueDialog", () => { expect(mockIssuesApi.create).toHaveBeenCalledWith( "company-1", expect.objectContaining({ - title: "Answer this first", + description: "Answer this first", workMode: "ask", }), ); @@ -1246,41 +1469,40 @@ describe("NewIssueDialog", () => { 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"); - expect(modeChip()?.textContent).toContain("Auto mode"); + const modeChip = () => container.querySelector("[data-testid=task-chat-composer-mode]"); + expect(modeChip()).toBeNull(); await act(async () => { - modeChip()?.dispatchEvent(new KeyboardEvent("keydown", { + container.querySelector(".paperclip-task-chat-composer")?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, code: "", key: ".", metaKey: true, })); }); - expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning"); + expect(modeChip()?.getAttribute("data-pending-work-mode")).toBe("planning"); expect(modeChip()?.textContent).toContain("Plan mode"); await act(async () => { - modeChip()?.dispatchEvent(new KeyboardEvent("keydown", { + container.querySelector(".paperclip-task-chat-composer")?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, code: "Period", key: ".", metaKey: true, })); }); - expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("ask"); + expect(modeChip()?.getAttribute("data-pending-work-mode")).toBe("ask"); expect(modeChip()?.textContent).toContain("Ask mode"); await act(async () => { - modeChip()?.dispatchEvent(new KeyboardEvent("keydown", { + container.querySelector(".paperclip-task-chat-composer")?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, code: "Period", key: ".", metaKey: true, })); }); - expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("standard"); + expect(modeChip()).toBeNull(); act(() => root.unmount()); }); @@ -1289,8 +1511,8 @@ describe("NewIssueDialog", () => { 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"); + const modeChip = () => container.querySelector("[data-testid=task-chat-composer-mode]"); + expect(modeChip()).toBeNull(); expect(dialogContentState.onEscapeKeyDown).not.toBeNull(); const commandPeriodAsEscape = new KeyboardEvent("keydown", { @@ -1304,7 +1526,7 @@ describe("NewIssueDialog", () => { }); expect(commandPeriodAsEscape.defaultPrevented).toBe(true); - expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning"); + expect(modeChip()?.getAttribute("data-pending-work-mode")).toBe("planning"); expect(dialogState.closeNewIssue).not.toHaveBeenCalled(); const plainEscape = new KeyboardEvent("keydown", { @@ -1317,7 +1539,7 @@ describe("NewIssueDialog", () => { }); expect(plainEscape.defaultPrevented).toBe(false); - expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning"); + expect(modeChip()?.getAttribute("data-pending-work-mode")).toBe("planning"); const controlEscape = new KeyboardEvent("keydown", { bubbles: true, @@ -1330,7 +1552,7 @@ describe("NewIssueDialog", () => { }); expect(controlEscape.defaultPrevented).toBe(false); - expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning"); + expect(modeChip()?.getAttribute("data-pending-work-mode")).toBe("planning"); act(() => root.unmount()); }); @@ -1339,18 +1561,18 @@ describe("NewIssueDialog", () => { 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"); + const modeChip = () => container.querySelector("[data-testid=task-chat-composer-mode]"); + expect(modeChip()).toBeNull(); await act(async () => { - modeChip()?.dispatchEvent(new KeyboardEvent("keydown", { + container.querySelector(".paperclip-task-chat-composer")?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, code: "Period", key: ".", ctrlKey: true, })); }); - expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning"); + expect(modeChip()?.getAttribute("data-pending-work-mode")).toBe("planning"); act(() => root.unmount()); }); @@ -1370,7 +1592,7 @@ describe("NewIssueDialog", () => { await flush(); const submitButton = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Create Sub-Task")); + .find((button) => button.getAttribute("aria-label") === "Create sub-task"); expect(submitButton).not.toBeUndefined(); await act(async () => { @@ -1402,14 +1624,12 @@ describe("NewIssueDialog", () => { expect(dialogContent?.className).toContain("h-(--new-issue-dialog-height)"); expect(dialogContent?.className).toContain("overflow-hidden"); - const titleInput = container.querySelector('textarea[placeholder="Task title (optional)"]'); - const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]'); + const descriptionInput = container.querySelector('textarea[aria-label="Describe a task…"]'); const bodyScrollRegion = Array.from(container.querySelectorAll("div")).find((element) => typeof element.className === "string" && element.className.includes("overscroll-contain"), ); - expect(bodyScrollRegion?.className).toContain("flex-1"); + expect(bodyScrollRegion?.className).toContain("min-h-0"); expect(bodyScrollRegion?.className).toContain("overflow-y-auto"); - expect(bodyScrollRegion?.contains(titleInput ?? null)).toBe(true); expect(bodyScrollRegion?.contains(descriptionInput ?? null)).toBe(true); act(() => root.unmount()); @@ -1438,7 +1658,7 @@ describe("NewIssueDialog", () => { element.className.includes("max-h-(--new-issue-dialog-height)"), ); const descriptionInput = container.querySelector( - 'textarea[aria-label="Add description..."]', + 'textarea[aria-label="Describe a task…"]', ); const scrollIntoView = vi.fn(); Object.defineProperty(descriptionInput!, "scrollIntoView", { @@ -1448,7 +1668,7 @@ describe("NewIssueDialog", () => { descriptionInput?.focus(); expect(dialogContent?.style.top).toBe(""); - expect(dialogContent?.style.height).toBe(""); + expect(dialogContent?.style.maxHeight).toBe(""); expect(dialogContent?.style.translate).toBe(""); visualViewport.height = 420; @@ -1466,7 +1686,7 @@ describe("NewIssueDialog", () => { "calc(var(--new-issue-visual-viewport-height) - var(--new-issue-dialog-top-gap) - var(--new-issue-dialog-bottom-gap))", ); expect(dialogContent?.style.top).toBe("var(--new-issue-dialog-top)"); - expect(dialogContent?.style.height).toBe("var(--new-issue-dialog-height)"); + expect(dialogContent?.style.maxHeight).toBe("var(--new-issue-dialog-height)"); expect(dialogContent?.style.translate).toBe("var(--pct-neg-50)"); expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" }); @@ -1497,7 +1717,7 @@ describe("NewIssueDialog", () => { ); expect(dialogContent?.style.getPropertyValue("--new-issue-visual-viewport-height")).toBe(""); expect(dialogContent?.style.top).toBe(""); - expect(dialogContent?.style.height).toBe(""); + expect(dialogContent?.style.maxHeight).toBe(""); expect(dialogContent?.style.translate).toBe(""); visualViewport.height = 420; @@ -1508,7 +1728,7 @@ describe("NewIssueDialog", () => { expect(dialogContent?.style.getPropertyValue("--new-issue-visual-viewport-height")).toBe("420px"); expect(dialogContent?.style.top).toBe("var(--new-issue-dialog-top)"); - expect(dialogContent?.style.height).toBe("var(--new-issue-dialog-height)"); + expect(dialogContent?.style.maxHeight).toBe("var(--new-issue-dialog-height)"); visualViewport.height = 0; visualViewport.offsetTop = Number.NaN; @@ -1518,7 +1738,7 @@ describe("NewIssueDialog", () => { expect(dialogContent?.style.getPropertyValue("--new-issue-visual-viewport-height")).toBe("420px"); expect(dialogContent?.style.top).toBe("var(--new-issue-dialog-top)"); - expect(dialogContent?.style.height).toBe("var(--new-issue-dialog-height)"); + expect(dialogContent?.style.maxHeight).toBe("var(--new-issue-dialog-height)"); act(() => root.unmount()); }); @@ -1547,7 +1767,7 @@ describe("NewIssueDialog", () => { await flush(); const submitButton = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Create Task")); + .find((button) => button.getAttribute("aria-label") === "Create task"); expect(submitButton).not.toBeUndefined(); await vi.waitFor(() => { expect(submitButton?.hasAttribute("disabled")).toBe(false); @@ -1591,101 +1811,7 @@ describe("NewIssueDialog", () => { act(() => root.unmount()); }); - it("warns when a sub-issue stops matching the parent workspace", async () => { - mockProjectsApi.list.mockResolvedValue([ - { - id: "project-1", - name: "Alpha", - description: null, - archivedAt: null, - color: "#445566", - executionWorkspacePolicy: { - enabled: true, - defaultMode: "shared_workspace", - }, - }, - ]); - mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([ - { - id: "workspace-1", - name: "Parent workspace", - mode: "isolated_workspace", - status: "active", - branchName: "feature/pap-1", - cwd: "/tmp/workspace-1", - projectWorkspaceId: null, - lastUsedAt: new Date("2026-04-06T16:00:00.000Z"), - }, - { - id: "workspace-2", - name: "Other workspace", - mode: "isolated_workspace", - status: "active", - branchName: "feature/pap-2", - cwd: "/tmp/workspace-2", - projectWorkspaceId: null, - lastUsedAt: new Date("2026-04-06T16:01:00.000Z"), - }, - ]); - mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); - dialogState.newIssueDefaults = { - parentId: "issue-1", - parentIdentifier: "PAP-1", - parentTitle: "Parent issue", - title: "Child issue", - projectId: "project-1", - executionWorkspaceId: "workspace-1", - parentExecutionWorkspaceLabel: "Parent workspace", - goalId: "goal-1", - }; - const { root } = renderDialog(container); - await flush(); - await flush(); - - expect(container.textContent).not.toContain("will no longer use the parent task workspace"); - - const selects = Array.from(container.querySelectorAll("select")); - const modeSelect = selects[0] as HTMLSelectElement | undefined; - expect(modeSelect).not.toBeUndefined(); - - await act(async () => { - modeSelect!.value = "shared_workspace"; - modeSelect!.dispatchEvent(new Event("change", { bubbles: true })); - }); - await flush(); - - expect(container.textContent).toContain("will no longer use the parent task workspace"); - expect(container.textContent).toContain("Parent workspace"); - - act(() => root.unmount()); - }); - - it("reveals the watchdog editor from the overflow menu", async () => { - mockInstanceSettingsApi.getExperimental.mockResolvedValue({ - enableIsolatedWorkspaces: false, - }); - - const { root } = renderDialog(container); - await flush(); - - // The watchdog row is hidden until the menu item is toggled on. - expect(container.querySelector('textarea[placeholder^="What should the watchdog"]')).toBeNull(); - - const watchdogMenuItem = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.trim() === "Watchdog"); - expect(watchdogMenuItem).not.toBeUndefined(); - - await act(async () => { - watchdogMenuItem!.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - await flush(); - - expect(container.textContent).toContain("Set watchdog"); - expect(container.querySelector('textarea[placeholder^="What should the watchdog"]')).not.toBeNull(); - - act(() => root.unmount()); - }); it("submits the configured watchdog from a restored draft", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ @@ -1714,10 +1840,9 @@ describe("NewIssueDialog", () => { const { root } = renderDialog(container); await flush(); - expect(container.textContent).toContain("Keep it moving"); const submitButton = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Create Task")); + .find((button) => button.getAttribute("aria-label") === "Create task"); expect(submitButton).not.toBeUndefined(); await vi.waitFor(() => { expect(submitButton?.hasAttribute("disabled")).toBe(false); @@ -1739,66 +1864,57 @@ describe("NewIssueDialog", () => { act(() => root.unmount()); }); - describe("graduated work-mode labels and status hues", () => { + it("retains a failed creation draft and allows retry through the composer", async () => { + mockIssuesApi.create.mockRejectedValueOnce(new Error("Service unavailable")); + dialogState.newIssueDefaults = { description: "Keep this request for retry" }; + const { root } = renderDialog(container); + await flush(); + const send = container.querySelector('[aria-label="Create task"]')!; + await act(async () => { send.click(); }); + await waitForAssertion(() => expect(container.querySelector('[role="alert"]')?.textContent).toContain("Service unavailable")); + expect(container.querySelector('textarea[aria-label="Describe a task…"]')?.value).toBe("Keep this request for retry"); + expect(dialogState.closeNewIssue).not.toHaveBeenCalled(); + await act(async () => { send.click(); }); + await waitForAssertion(() => expect(dialogState.closeNewIssue).toHaveBeenCalledOnce()); + expect(mockIssuesApi.create).toHaveBeenCalledTimes(2); + act(() => root.unmount()); + }); + + it("restores shared model, effort, and fast settings into the created task", async () => { + mockAgentsApi.list.mockResolvedValue([{ id: "agent-1", name: "Coder", status: "active", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} }]); + localStorage.setItem("paperclip:issue-draft", JSON.stringify({ + title: "", description: "Use the saved run settings", status: "todo", priority: "medium", + assigneeValue: "agent:agent-1", projectId: "", workMode: "planning", + composerSettings: { model: "gpt-6-astra", effort: "ultra", fast: true }, + })); + const { root } = renderDialog(container); + await waitForAssertion(() => expect(container.textContent).toContain("Coder")); + await act(async () => { container.querySelector('[aria-label="Create task"]')!.click(); }); + await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ + assigneeAgentId: "agent-1", workMode: "planning", + assigneeAdapterOverrides: { adapterConfig: { model: "gpt-6-astra", modelReasoningEffort: "ultra", fastMode: true } }, + }))); + act(() => root.unmount()); + }); + + describe("work-mode labels", () => { function workModeOption(value: string) { - return container.querySelector(`[data-issue-work-mode="${value}"]`); + return container.querySelector(`[data-testid="composer-add-${value === "planning" ? "plan" : value}"]`); } - function statusOptionIconClass(label: string, description?: string) { - const button = Array.from(container.querySelectorAll("button")).find((candidate) => { - const text = candidate.textContent ?? ""; - return ( - candidate.querySelector("svg") !== null && - text.includes(label) && - (description === undefined || text.includes(description)) - ); - }); - return button?.querySelector("svg")?.getAttribute("class") ?? ""; - } - - it("uses auto-mode labels and brand status hues by default", async () => { + it("uses the shared composer mode labels", async () => { const { root } = renderDialog(container); await waitForAssertion(() => { - expect(workModeOption("standard")?.textContent).toContain("Auto mode"); + expect(container.querySelector("[data-testid=task-chat-composer-add]")).not.toBeNull(); }); - expect(workModeOption("standard")?.textContent).toContain("Auto mode"); + expect(container.querySelector("[data-testid=task-chat-composer-add]")).not.toBeNull(); expect(workModeOption("ask")?.textContent).toContain("Ask mode"); expect(workModeOption("planning")?.textContent).toContain("Plan mode"); - expect(statusOptionIconClass("Todo", "Executable - assignee will be woken")).toContain("text-amber-600"); - expect(statusOptionIconClass("In Progress")).toContain("text-blue-600"); act(() => root.unmount()); }); }); - describe("PAP-8501: company badge shows issuePrefix", () => { - it("displays issuePrefix instead of name-derived prefix", async () => { - // Override company data to have mismatched name/prefix - companyState.companies = [ - { - id: "company-1", - name: "Acme Labs", - status: "active", - issuePrefix: "OPS", - }, - ]; - companyState.selectedCompany = { - id: "company-1", - name: "Acme Labs", - status: "active", - issuePrefix: "OPS", - }; - - const { root } = renderDialog(container); - await waitForAssertion(() => { - const text = container.textContent ?? ""; - // Should show OPS (issuePrefix), not ACM (name.slice(0,3)) - expect(text).toContain("OPS"); - }); - - act(() => root.unmount()); - }); - }); }); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 36ef947261c..73be4ff291a 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1,7 +1,5 @@ import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls"; -import { AgentAvatar } from "@/components/AgentAvatar"; -import { normalizeLegacyRunnerProvider } from "@paperclipai/adapter-utils"; -import { memo, useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type CSSProperties, type DragEvent, type RefObject } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo, type CSSProperties, type DragEvent } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import type { AgentEnvConfig, EnvBinding, IssueWorkMode } from "@paperclipai/shared"; import { useDialog } from "../context/DialogContext"; @@ -18,7 +16,6 @@ import { assetsApi } from "../api/assets"; import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions, isAgentTaskTarget } from "../lib/company-members"; import { queryKeys } from "../lib/queryKeys"; import { useNavigate } from "../lib/router"; -import { orderReusableExecutionWorkspaces } from "../lib/reusable-execution-workspaces"; import { defaultExecutionWorkspaceModeForProject, defaultProjectWorkspaceIdForProject, @@ -26,66 +23,27 @@ import { } from "../lib/project-workspace-defaults"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled"; -import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; -import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects"; +import { getRecentAssigneeIds, getRecentAssigneeSelectionIds, sortAgentsByRecency, trackRecentAssignee, trackRecentAssigneeUser } from "../lib/recent-assignees"; +import { getLastProjectId, getRecentProjectIds, trackRecentProject } from "../lib/recent-projects"; import { recordRecentTask } from "../lib/recent-tasks"; import { buildExecutionPolicy } from "../lib/issue-execution-policy"; import { createUuid } from "../lib/uuid"; -import { isIssueWorkMode, nextWorkMode, workModeMetaFor, workModeMetaList } from "../lib/work-mode-meta"; +import { isIssueWorkMode, nextWorkMode } from "../lib/work-mode-meta"; import { useToastActions } from "../context/ToastContext"; -import { - assigneeValueFromSelection, - currentUserAssigneeOption, - parseAssigneeValue, -} from "../lib/assignees"; -import { - Dialog, - DialogContent, -} from "@/components/ui/dialog"; +import { assigneeValueFromSelection, currentUserAssigneeOption, parseAssigneeValue } from "../lib/assignees"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { ToggleSwitch } from "@/components/ui/toggle-switch"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Maximize2, - Minimize2, - MoreHorizontal, - ChevronRight, - ChevronDown, - Check, - CircleDot, - Minus, - ArrowUp, - ArrowDown, - AlertTriangle, - Tag, - Calendar, - Paperclip, - FileText, - Flag, - PauseCircle, - Loader2, - ListTree, - X, - Eye, - ShieldAlert, - ShieldCheck, - ScanEye, -} from "lucide-react"; -import { Textarea } from "@/components/ui/textarea"; +import { Paperclip, FileText, Flag, PauseCircle, ListTree, X, ShieldAlert, Folder, ChevronDown } from "lucide-react"; import { cn } from "../lib/utils"; -import { extractProviderIdWithFallback } from "../lib/model-utils"; -import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors"; -import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags"; -import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; +import type { MentionOption } from "./MarkdownEditor"; +import { TaskChatComposer } from "./task-chat/TaskChatComposer"; +import { ComposerWorktreePicker } from "./task-chat/ComposerWorktreePicker"; +import { TaskChatPresentationProvider } from "./task-chat/presentation-mode"; +import { mergeComposerRunSettings, type ComposerRunSettings } from "./task-chat/composer-run-settings"; +import { useSidebar } from "../context/SidebarContext"; import { InlineBanner } from "./InlineBanner"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; import { getTrustPreset } from "../lib/trust-policy-ui"; -import { ReusableExecutionWorkspaceSelect } from "./ReusableExecutionWorkspaceSelect"; -import { codexReasoningEffortOptions } from "../lib/codex-reasoning-effort"; const DRAFT_KEY = "paperclip:issue-draft"; const DEBOUNCE_MS = 800; @@ -111,12 +69,7 @@ function readVisualViewportLayout(): VisualViewportLayout | null { const { height, offsetTop } = window.visualViewport; // Mobile browsers can briefly report unusable geometry while the visual // viewport initializes or animates. Applying it collapses the dialog. - if ( - !Number.isFinite(height) - || height <= 0 - || !Number.isFinite(offsetTop) - || offsetTop < 0 - ) { + if (!Number.isFinite(height) || height <= 0 || !Number.isFinite(offsetTop) || offsetTop < 0) { return null; } return { @@ -180,6 +133,7 @@ interface IssueDraft { selectedExecutionWorkspaceId?: string; useIsolatedExecutionWorkspace?: boolean; workMode?: IssueWorkMode; + composerSettings?: ComposerRunSettings | null; } type StagedIssueFile = { @@ -191,31 +145,7 @@ type StagedIssueFile = { }; import { Badge } from "@/components/ui/badge"; -import { - buildAssigneeAdapterOverrides, - ISSUE_OVERRIDE_ADAPTER_TYPES, - type IssueModelLane, -} from "../lib/issue-assignee-overrides"; - -const STAGED_FILE_ACCEPT = "image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown"; - -const ISSUE_THINKING_EFFORT_OPTIONS = { - claude_local: [ - { value: "", label: "Default" }, - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - ], - opencode_local: [ - { value: "", label: "Default" }, - { value: "minimal", label: "Minimal" }, - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "xhigh", label: "X-High" }, - { value: "max", label: "Max" }, - ], -} as const; +import { buildAssigneeAdapterOverrides, type IssueModelLane } from "../lib/issue-assignee-overrides"; function loadDraft(): IssueDraft | null { try { @@ -288,48 +218,31 @@ function formatFileSize(file: File) { return `${(file.size / (1024 * 1024)).toFixed(1)} MB`; } -function buildStatusOptions(): ReadonlyArray<{ value: string; label: string; color: string; description?: string }> { - const palette = issueStatusText; - return [ - { - value: "backlog", - label: "Backlog", - color: palette.backlog ?? issueStatusTextDefault, - description: "Parked - assignee will not be woken", - }, - { - value: "todo", - label: "Todo", - color: palette.todo ?? issueStatusTextDefault, - description: "Executable - assignee will be woken", - }, - { value: "in_progress", label: "In Progress", color: palette.in_progress ?? issueStatusTextDefault }, - { value: "in_review", label: "In Review", color: palette.in_review ?? issueStatusTextDefault }, - { value: "done", label: "Done", color: palette.done ?? issueStatusTextDefault }, - ]; -} - function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function isRequiredUserSecretBinding(value: unknown): value is Extract { - return isRecord(value) - && value.type === "user_secret_ref" - && typeof value.key === "string" - && value.key.trim().length > 0 - && value.required !== false - && value.allowMissingOverride !== true; -} - -function collectRequiredUserSecretKeysFromEnv(env: AgentEnvConfig | Record | null | undefined): string[] { - if (!isRecord(env)) return []; - return Object.values(env).flatMap((binding) => - isRequiredUserSecretBinding(binding) ? [binding.key.trim()] : [], + return ( + isRecord(value) && + value.type === "user_secret_ref" && + typeof value.key === "string" && + value.key.trim().length > 0 && + value.required !== false && + value.allowMissingOverride !== true ); } -function uniqueRequiredUserSecretKeys(inputs: Array | null | undefined>): string[] { +function collectRequiredUserSecretKeysFromEnv( + env: AgentEnvConfig | Record | null | undefined, +): string[] { + if (!isRecord(env)) return []; + return Object.values(env).flatMap((binding) => (isRequiredUserSecretBinding(binding) ? [binding.key.trim()] : [])); +} + +function uniqueRequiredUserSecretKeys( + inputs: Array | null | undefined>, +): string[] { return [...new Set(inputs.flatMap(collectRequiredUserSecretKeysFromEnv))]; } @@ -337,19 +250,6 @@ function shouldWarnAboutRunUserSecrets(status: string, assigneeAgentId: string | return Boolean(assigneeAgentId) && (status === "todo" || status === "in_progress"); } -const priorities = [ - { value: "critical", label: "Critical", icon: AlertTriangle, color: priorityColor.critical ?? priorityColorDefault }, - { value: "high", label: "High", icon: ArrowUp, color: priorityColor.high ?? priorityColorDefault }, - { value: "medium", label: "Medium", icon: Minus, color: priorityColor.medium ?? priorityColorDefault }, - { value: "low", label: "Low", icon: ArrowDown, color: priorityColor.low ?? priorityColorDefault }, -]; - -const EXECUTION_WORKSPACE_MODES = [ - { value: "shared_workspace", label: "Project default" }, - { value: "isolated_workspace", label: "New isolated workspace" }, - { value: "reuse_existing", label: "Reuse existing workspace" }, -] as const; - function defaultExecutionWorkspaceModeForIssueDefaults( defaults: { executionWorkspaceId?: unknown; @@ -374,143 +274,30 @@ function isWorkModeEscapeShortcut(e: Pick) { return e.metaKey && e.key === "Escape"; } -const IssueTitleTextarea = memo(function IssueTitleTextarea({ - value, - pending, - assigneeValue, - projectId, - descriptionEditorRef, - assigneeSelectorRef, - projectSelectorRef, - onChange, -}: { - value: string; - pending: boolean; - assigneeValue: string; - projectId: string; - descriptionEditorRef: RefObject; - assigneeSelectorRef: RefObject; - projectSelectorRef: RefObject; - onChange: (value: string) => void; -}) { - const [draftValue, setDraftValue] = useState(value); - - useEffect(() => { - setDraftValue(value); - }, [value]); - - return ( -