From 2c43b39167117c025f45a09d984cc88919318a84 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:06:40 -0500 Subject: [PATCH] refactor(ui): share task composer and project/worktree controls (#15091) Use the shared composer for new tasks, including project and worktree selection, rich mentions and slash commands, and remembered task settings. Save project preferences after successful task updates. Show known agent models by name and use Default for unknown runtime defaults. Co-Authored-By: Paperclip --- doc/DEVELOPING.md | 41 + tests/e2e/new-task-picker-touch.spec.ts | 81 +- .../components/InlineEntitySelector.test.tsx | 53 + ui/src/components/InlineEntitySelector.tsx | 104 +- ui/src/components/IssueProperties.test.tsx | 22 + ui/src/components/MarkdownBody.test.tsx | 23 +- ui/src/components/MarkdownBody.tsx | 39 +- ui/src/components/MarkdownEditor.test.tsx | 5 + ui/src/components/MarkdownEditor.tsx | 8 +- ui/src/components/NewIssueDialog.test.tsx | 746 +++--- ui/src/components/NewIssueDialog.tsx | 2142 +++++------------ ui/src/components/NewProjectDialog.test.tsx | 72 + ui/src/components/NewProjectDialog.tsx | 14 +- ui/src/components/SearchableSelect.test.tsx | 50 + ui/src/components/SearchableSelect.tsx | 21 +- .../issue-properties/IssueProperties.tsx | 7 +- .../ComposerRunSettingsPicker.test.tsx | 106 +- .../task-chat/ComposerRunSettingsPicker.tsx | 83 +- .../task-chat/ComposerWorktreePicker.tsx | 109 + .../task-chat/TaskChatComposer.test.tsx | 48 + .../components/task-chat/TaskChatComposer.tsx | 125 +- .../task-chat/TaskChatComposerBar.tsx | 15 + .../task-chat/TaskChatQueuedMessages.tsx | 6 +- .../task-chat/composer-run-settings.test.ts | 15 +- .../task-chat/composer-run-settings.ts | 24 + ui/src/index.css | 25 +- ui/src/lib/mention-aware-link-node.test.ts | 25 + ui/src/lib/mention-aware-link-node.ts | 2 +- ui/src/lib/mention-chips.test.ts | 41 +- ui/src/lib/mention-chips.ts | 81 +- ui/src/lib/recent-assignees.ts | 10 +- ui/src/lib/recent-composer-effort.ts | 21 + ui/src/lib/recent-projects.ts | 18 +- ui/src/lib/recent-selections.test.ts | 37 +- ui/src/pages/IssueDetail.test.tsx | 29 + ui/src/pages/IssueDetail.tsx | 6 +- ui/storybook/fixtures/agent-avatar-url.ts | 7 +- .../ComposerModelPickerPreview.tsx | 2 +- .../composer-model-picker/fixtures.ts | 2 +- .../stories/composer-model-picker.stories.tsx | 2 +- .../stories/dialogs-modals.stories.tsx | 20 +- .../stories/new-task-composer.stories.tsx | 534 ++++ 42 files changed, 2642 insertions(+), 2179 deletions(-) create mode 100644 ui/src/components/NewProjectDialog.test.tsx create mode 100644 ui/src/components/task-chat/ComposerWorktreePicker.tsx create mode 100644 ui/src/components/task-chat/TaskChatComposerBar.tsx create mode 100644 ui/src/lib/recent-composer-effort.ts create mode 100644 ui/storybook/stories/new-task-composer.stories.tsx 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 ( -