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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-05 18:06:40 -05:00
1 parent 984f092ddf
commit 2c43b39167
42 files changed
+2642 -2179

No files matched your search

+41
View File
@@ -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.
+42 -39
View File
@@ -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");
});
@@ -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(
<Dialog defaultOpen>
<DialogContent aria-describedby={undefined}>
<DialogTitle>New task</DialogTitle>
<InlineEntitySelector
value="project-1"
options={[{ id: "project-1", label: "Board UI" }]}
placeholder="Project"
noneLabel="No project"
searchPlaceholder="Search projects..."
emptyMessage="No projects found."
onChange={onChange}
triggerTestId="project-picker"
openOnFocus={false}
/>
</DialogContent>
</Dialog>,
);
});
try {
const trigger = document.querySelector<HTMLButtonElement>('[data-testid="project-picker"]')!;
await act(() => trigger.click());
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
const backdrop = document.querySelector<HTMLElement>("[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();
+56 -48
View File
@@ -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<HTMLButtonElement, InlineEntitySe
options,
placeholder,
noneLabel,
noneAtEnd = false,
searchPlaceholder,
emptyMessage,
onChange,
@@ -99,8 +102,9 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
const allOptions = useMemo<InlineEntityOption[]>(() => {
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<HTMLButtonElement, InlineEntitySe
<X className="size-5" />
</button>
</div>
<input
ref={inputRef}
className="w-full border-b border-border bg-transparent px-2 py-1.5 text-base outline-none placeholder:text-muted-foreground/60 md:text-sm"
placeholder={searchPlaceholder}
value={query}
onChange={(event) => {
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);
}
}}
/>
<div className="flex items-center gap-2 border-b border-border px-2">
<Search className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<input
ref={inputRef}
className="min-w-0 flex-1 bg-transparent py-2 text-base outline-none placeholder:text-muted-foreground/60 md:text-sm"
placeholder={searchPlaceholder}
value={query}
onChange={(event) => {
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);
}
}}
/>
</div>
<div data-mobile-entity-picker-list="" className="max-h-56 overflow-y-auto overscroll-contain py-1 touch-pan-y">
{filteredOptions.length === 0 ? (
<p className="px-2 py-2 text-xs text-muted-foreground">{emptyMessage}</p>
@@ -273,6 +280,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
type="button"
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm touch-manipulation",
noneAtEnd && !option.id && "mt-1 rounded-none border-t border-border",
isHighlighted && "bg-accent",
)}
onMouseEnter={() => setHighlightedIndexValue(index)}
@@ -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<HTMLButtonElement>("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<HTMLButtonElement>("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";
+21 -2
View File
@@ -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(
<QueryClientProvider client={client}>
<ThemeProvider>
<MarkdownBody>{`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")})`}</MarkdownBody>
</ThemeProvider>
</QueryClientProvider>,
);
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(
'<script>fetch("/api/secrets")</script>\n<iframe src="https://example.com"></iframe>\n<p onclick="steal()">Plain text</p>',
+36 -3
View File
@@ -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<Agent[]>({
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 (
<a
href={`/agents/${agentId}`}
className="paperclip-mention-chip paperclip-mention-chip--agent"
data-mention-kind="agent"
style={{ ...mergeWrapStyle(style), ...mentionChipInlineStyle({ kind: "agent", agentId, icon: null, appearance }) }}
>
{children}
</a>
);
}
/**
* 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 (
<MarkdownAgentMention agentId={parsed.agentId} style={linkStyle as React.CSSProperties | undefined}>
{linkChildren}
</MarkdownAgentMention>
);
}
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 (
<a
href={targetHref}
@@ -1007,6 +1007,7 @@ describe("MarkdownEditor", () => {
).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,
});
});
+6 -2
View File
@@ -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<MarkdownEditorRef, MarkdownEditorProps>
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<MarkdownEditorRef, MarkdownEditorProps>
style={{
top: mentionMenuPosition.top,
left: mentionMenuPosition.left,
maxWidth: mentionMenuPosition.maxWidth,
touchAction: "pan-y",
WebkitOverflowScrolling: "touch",
}}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
import { act, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PROJECT_COLORS } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { projectsApi } from "../api/projects";
import { getLastProjectId } from "../lib/recent-projects";
import { NewProjectForm } from "./NewProjectDialog";
vi.mock("../api/projects", () => ({ projectsApi: { create: vi.fn() } }));
vi.mock("./ProjectRepositoryInput", () => ({
ProjectRepositoryInput: () => null,
repositoryOptionsKey: (companyId: string) => ["repositories", companyId],
}));
vi.mock("@/features/connections/ConnectionSetupFlow", () => ({ ConnectionSetupFlow: () => null }));
vi.mock("./ui/dialog", () => ({
Dialog: ({ children }: { children: ReactNode }) => <>{children}</>,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
}));
let root: ReturnType<typeof createRoot>;
let container: HTMLDivElement;
beforeEach(() => {
localStorage.clear();
vi.mocked(projectsApi.create).mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.restoreAllMocks();
});
async function settle() {
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); });
}
describe("new project colors", () => {
it("previews a palette color, keeps it through a retry, and remembers the created project", async () => {
vi.mocked(projectsApi.create)
.mockRejectedValueOnce(new Error("Try again"))
.mockResolvedValueOnce({ id: "project-created" } as Awaited<ReturnType<typeof projectsApi.create>>);
const onClose = vi.fn();
act(() => root.render(<QueryClientProvider client={new QueryClient({ defaultOptions: { mutations: { retry: false } } })}>
<NewProjectForm companyId="company-1" onClose={onClose} />
</QueryClientProvider>));
const previewColor = container.querySelector<HTMLElement>('span[style]')!.style.backgroundColor;
const input = container.querySelector<HTMLInputElement>('input[aria-label="Project name"]')!;
act(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "Colorful project");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
act(() => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
await settle();
expect(container.querySelector('[role="alert"]')?.textContent).toBe("Try again");
const firstColor = vi.mocked(projectsApi.create).mock.calls[0][1].color;
expect(PROJECT_COLORS).toContain(firstColor);
const expected = document.createElement("span");
expected.style.backgroundColor = String(firstColor);
expect(previewColor).toBe(expected.style.backgroundColor);
act(() => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
await settle();
expect(vi.mocked(projectsApi.create).mock.calls[1][1]).toEqual({ name: "Colorful project", color: firstColor, status: "planned", repositoryIds: [] });
expect(getLastProjectId("company-1")).toBe("project-created");
expect(onClose).toHaveBeenCalledOnce();
});
});
+9 -5
View File
@@ -1,11 +1,13 @@
import { useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { ProjectRepository } from "@paperclipai/shared";
import { Folder, X } from "lucide-react";
import { PROJECT_COLORS, type ProjectRepository } from "@paperclipai/shared";
import { X } from "lucide-react";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { projectsApi } from "../api/projects";
import { queryKeys } from "../lib/queryKeys";
import { trackRecentProject } from "../lib/recent-projects";
import { ProjectTile } from "./ProjectTile";
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
import { Button } from "./ui/button";
import { ProjectRepositoryInput, repositoryOptionsKey } from "./ProjectRepositoryInput";
@@ -21,12 +23,14 @@ export function NewProjectDialog() {
export function NewProjectForm({ companyId, onClose }: { companyId: string; onClose: () => void }) {
const client = useQueryClient();
const [name, setName] = useState("");
const [color] = useState(() => PROJECT_COLORS[Math.floor(Math.random() * PROJECT_COLORS.length)]!);
const [repos, setRepos] = useState<ProjectRepository[]>([]);
const [connecting, setConnecting] = useState(false);
const input = useRef<HTMLInputElement>(null);
const create = useMutation({
mutationFn: () => projectsApi.create(companyId, { name: name.trim(), status: "planned", repositoryIds: repos.map((repo) => repo.id) }),
onSuccess: () => {
mutationFn: () => projectsApi.create(companyId, { name: name.trim(), color, status: "planned", repositoryIds: repos.map((repo) => repo.id) }),
onSuccess: (project) => {
trackRecentProject(project.id, companyId);
void client.invalidateQueries({ queryKey: queryKeys.projects.all(companyId) });
onClose();
},
@@ -48,7 +52,7 @@ export function NewProjectForm({ companyId, onClose }: { companyId: string; onCl
<Button type="button" variant="ghost" size="icon-sm" disabled={create.isPending} aria-label="Close new project" onClick={onClose}><X className="size-4" /></Button>
</div>
<div className="flex items-center gap-3 rounded-lg border border-input px-3 focus-within:border-ring focus-within:ring-1 focus-within:ring-ring">
<Folder className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<ProjectTile color={color} size="sm" />
<input ref={input} aria-label="Project name" value={name} disabled={create.isPending} onChange={(event) => setName(event.target.value)} placeholder="Project name" required
className="h-10 w-full min-w-0 border-0 bg-transparent text-base outline-none placeholder:text-muted-foreground md:text-sm" />
</div>
@@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SearchableSelect, type SearchableSelectGroup, type SearchableSelectOption } from "./SearchableSelect";
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
import {
buildReusableExecutionWorkspaceOptionGroups,
reusableWorkspaceOptionMatches,
@@ -393,6 +394,55 @@ describe("SearchableSelect", () => {
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
});
it.each([false, true])("dismisses outside a nested worktree picker (modal=%s) without reopening or closing the task", async (modal) => {
const onValueChange = vi.fn();
root = render(
<Dialog defaultOpen>
<DialogContent aria-describedby={undefined}>
<DialogTitle>New task</DialogTitle>
<button type="button" data-testid="outside-worktree">Task draft</button>
<SearchableSelect
value="new"
groups={[{ id: "all", options: [{ key: "new", value: "new", label: "New worktree" }] }]}
onValueChange={onValueChange}
placeholder="Worktrees"
searchPlaceholder="Search worktrees..."
modal={modal}
/>
</DialogContent>
</Dialog>,
container,
);
await flush();
const trigger = document.querySelector<HTMLButtonElement>('button[role="combobox"]')!;
const outside = document.querySelector<HTMLButtonElement>('[data-testid="outside-worktree"]')!;
act(() => trigger.click());
// Radix installs its outside pointer listener after the opening event.
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
const picker = document.querySelector<HTMLElement>("[data-mobile-entity-picker]")!;
const outsideTarget = modal ? picker.parentElement! : outside;
act(() => {
const pointerDown = new MouseEvent("pointerdown", { bubbles: true, cancelable: true });
Object.defineProperty(pointerDown, "pointerType", { value: modal ? "touch" : "mouse" });
outsideTarget.dispatchEvent(pointerDown);
if (!modal) outside.focus();
outsideTarget.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.getAttribute("aria-expanded")).toBe("false");
expect(trigger.textContent).toContain("New worktree");
expect(onValueChange).not.toHaveBeenCalled();
if (modal) expect(document.activeElement).toBe(trigger);
act(() => {
outside.focus();
trigger.focus();
});
await flush();
expect(document.querySelector("[data-mobile-entity-picker]")).not.toBeNull();
});
it("filters workspace options, moves with arrows, and selects the workspace id with Enter", async () => {
const onValueChange = vi.fn();
const groups = buildWorkspaceSelectGroups([
+19 -2
View File
@@ -1,5 +1,5 @@
import { Check, ChevronsUpDown, X } from "lucide-react";
import { useMemo, useRef, useState, type ReactNode } from "react";
import { useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
@@ -62,6 +62,10 @@ export interface SearchableSelectProps<
disablePortal?: boolean;
/** Heading for the large mobile selector modal. Defaults to the placeholder. */
mobileTitle?: string;
triggerAriaLabel?: string;
modal?: boolean;
contentStyle?: CSSProperties;
listFooter?: ReactNode;
/**
* Optional pinned "creatable" item rendered at the bottom of the list,
* regardless of the query (used e.g. by the secret picker's
@@ -111,6 +115,10 @@ export function SearchableSelect<
scoreOption,
disablePortal,
mobileTitle,
triggerAriaLabel,
modal,
contentStyle,
listFooter,
createItem,
}: SearchableSelectProps<TValue, TOption>) {
const [open, setOpen] = useState(false);
@@ -190,6 +198,7 @@ export function SearchableSelect<
return (
<Popover
modal={modal}
open={open}
onOpenChange={(next) => {
setOpen(next);
@@ -220,6 +229,7 @@ export function SearchableSelect<
}
}}
aria-expanded={open}
aria-label={triggerAriaLabel}
role="combobox"
className={cn("w-full justify-between overflow-hidden", className, triggerClassName)}
>
@@ -232,7 +242,7 @@ export function SearchableSelect<
<PopoverContent
data-mobile-entity-picker=""
aria-label={mobileTitle ?? placeholder}
style={mobileViewportStyle}
style={{ ...mobileViewportStyle, ...contentStyle }}
align={align}
collisionPadding={16}
disablePortal={disablePortal}
@@ -250,6 +260,12 @@ export function SearchableSelect<
closePopover({ suppressTriggerFocus: true });
}
}}
onCloseAutoFocus={() => {
// Modal outside dismissal restores focus to the trigger. Keep that
// restore from reopening the picker, without swallowing a later Tab.
suppressNextTriggerFocusRef.current = true;
queueMicrotask(() => { suppressNextTriggerFocusRef.current = false; });
}}
>
<div data-mobile-entity-picker-header="" className="hidden items-center justify-between border-b border-border px-4 py-3">
<span className="text-base font-semibold text-foreground">{mobileTitle ?? placeholder}</span>
@@ -325,6 +341,7 @@ export function SearchableSelect<
</CommandItem>
</CommandGroup>
) : null}
{listFooter}
</>
)}
</CommandList>
@@ -39,7 +39,7 @@ import {
trackRecentAssignee,
trackRecentAssigneeUser,
} from "../../lib/recent-assignees";
import { getRecentProjectIds, trackRecentProject } from "../../lib/recent-projects";
import { getRecentProjectIds } from "../../lib/recent-projects";
import { orderItemsBySelectedAndRecent } from "../../lib/recent-selections";
import { formatAssigneeUserLabel, formatUserLabel } from "../../lib/assignees";
import { buildExecutionPolicy, stageParticipantValues } from "../../lib/issue-execution-policy";
@@ -1762,11 +1762,11 @@ export function IssueProperties({
onClick={() => {
if (option.kind === "agent") {
selectAssignee({ assigneeAgentId: option.agent.id, assigneeUserId: null }, option.label, () =>
trackRecentAssignee(option.agent.id),
trackRecentAssignee(option.agent.id, companyId ?? undefined),
);
} else if (option.kind === "user") {
selectAssignee({ assigneeAgentId: null, assigneeUserId: option.userId }, option.label, () =>
trackRecentAssigneeUser(option.userId),
trackRecentAssigneeUser(option.userId, companyId ?? undefined),
);
} else {
selectAssignee({ assigneeAgentId: null, assigneeUserId: null }, option.label);
@@ -1995,7 +1995,6 @@ export function IssueProperties({
onClick={() => {
if (option.kind === "project") {
const defaultMode = defaultExecutionWorkspaceModeForProject(option.project);
trackRecentProject(option.project.id);
onUpdate({
projectId: option.project.id,
projectWorkspaceId: defaultProjectWorkspaceIdForProject(option.project),
@@ -1,13 +1,14 @@
// @vitest-environment jsdom
import { act } from "react";
import { act, type ComponentProps } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent } from "@paperclipai/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { agentsApi } from "@/api/agents";
import { ComposerRunSettingsPicker } from "./ComposerRunSettingsPicker";
import { getLastComposerEffort, rememberComposerEffort } from "@/lib/recent-composer-effort";
const agent = {
id: "a1", companyId: "company-1", name: "Clippy",
@@ -33,7 +34,7 @@ async function click(label: string) {
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
}
function render(onAssigneeChange: (value: string) => void, onSettingsChange: () => void, useCatalog = false) {
function render(onAssigneeChange: (value: string) => void, onSettingsChange: () => void, useCatalog = false, props: Partial<ComponentProps<typeof ComposerRunSettingsPicker>> = {}) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
@@ -42,10 +43,11 @@ function render(onAssigneeChange: (value: string) => void, onSettingsChange: ()
<ComposerRunSettingsPicker companyId="company-1" assigneeValue="agent:a1" currentAssigneeValue="agent:a1"
options={options} agents={agents} settings={{ model: "gpt-6-sol", effort: "high", fast: true }}
onAssigneeChange={onAssigneeChange} onSettingsChange={onSettingsChange}
modelOptionsOverride={useCatalog ? undefined : []} />
modelOptionsOverride={useCatalog ? undefined : []} {...props} />
</QueryClientProvider>));
}
beforeEach(() => localStorage.clear());
afterEach(() => {
vi.restoreAllMocks();
flushSync(() => root?.unmount());
@@ -55,11 +57,61 @@ afterEach(() => {
});
describe("composer assignee picker", () => {
it.each([
{ adapterType: "claude_local", config: {}, label: "Default" },
{ adapterType: "claude_local", config: { model: "claude-opus-5" }, label: "Claude Opus 5" },
{ adapterType: "claude_local", config: { env: { ANTHROPIC_MODEL: "claude-opus-5" } }, label: "Claude Opus 5" },
{ adapterType: "codex_local", config: {}, label: "Default" },
])("labels the $adapterType default honestly", ({ adapterType, config, label }) => {
const defaultAgent = { ...agent, adapterType, adapterConfig: config } as Agent;
render(vi.fn(), vi.fn(), false, { settings: null, agents: new Map([[agent.id, defaultAgent]]),
modelOptionsOverride: [{ id: "claude-opus-5", label: "Claude Opus 5" }] });
expect(container!.querySelector('[data-testid="task-chat-composer-model-label"]')?.textContent).toBe(label);
});
it("does not offer effort levels for an unknown Claude default", async () => {
const defaultAgent = { ...agent, adapterType: "claude_local", adapterConfig: {} } as Agent;
render(vi.fn(), vi.fn(), false, { settings: null, agents: new Map([[agent.id, defaultAgent]]),
modelOptionsOverride: [{ id: "claude-opus-5", label: "Claude Opus 5" }] });
await click("Select model and effort");
expect(document.querySelector('[aria-label="Effort"]')).toBeNull();
});
it.each([
{ remembered: "high", settings: null, overrides: null, restored: "high" },
{ remembered: "off", settings: null, overrides: null, restored: null },
{ remembered: "high", settings: { model: null, effort: "low", fast: false }, overrides: null, restored: null },
{ remembered: "high", settings: null, overrides: { adapterConfig: { modelReasoningEffort: "low" } }, restored: null },
])("restores compatible history ($remembered) without replacing explicit settings", ({ remembered, settings, overrides, restored }) => {
rememberComposerEffort("company-1", remembered);
const onSettingsChange = vi.fn();
render(vi.fn(), onSettingsChange, false, { settings, overrides });
if (restored) expect(onSettingsChange).toHaveBeenCalledWith({ model: null, effort: restored, fast: false });
else expect(onSettingsChange).not.toHaveBeenCalled();
});
it("records effort changes and preserves effort when changing to a compatible model", async () => {
const onSettingsChange = vi.fn();
render(vi.fn(), onSettingsChange, false, { modelOptionsOverride: [{ id: "gpt-6-astra", label: "GPT-6 Astra" }] });
await click("Select model and effort");
const range = document.querySelector<HTMLInputElement>('input[aria-label="Effort"]')!;
flushSync(() => {
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!.call(range, "1");
range.dispatchEvent(new Event("change", { bubbles: true }));
range.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(getLastComposerEffort("company-1")).toBe("low");
await click("Choose exact model");
await click("GPT-6 Astra");
expect(onSettingsChange).toHaveBeenLastCalledWith({ model: "gpt-6-astra", effort: "high", fast: false });
await click("Reset to agent default");
expect(getLastComposerEffort("company-1")).toBeNull();
});
it("lets the assignee and model use the available composer width", () => {
render(vi.fn(), vi.fn());
const trigger = container!.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-assignee"]');
const assignee = trigger!.querySelector('[data-testid="task-chat-composer-assignee-label"]');
const model = trigger!.querySelector('[data-testid="task-chat-composer-model-label"]');
const model = container!.querySelector('[data-testid="task-chat-composer-model-label"]');
expect(trigger?.className).toContain("max-w-full");
expect(trigger?.className).not.toContain("max-w-64");
@@ -72,8 +124,7 @@ describe("composer assignee picker", () => {
it("finds assignees by their displayed role and harness", async () => {
render(vi.fn(), vi.fn());
await click("Select assignee, model and effort");
await click("Choose assignee");
await click("Select assignee");
const input = document.querySelector<HTMLInputElement>('input[aria-label="Search assignees"]');
expect(input).not.toBeNull();
const setValue = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
@@ -94,7 +145,7 @@ describe("composer assignee picker", () => {
]);
render(vi.fn(), vi.fn(), true);
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
await click("Select assignee, model and effort");
await click("Select model and effort");
await click("Choose exact model");
const options = [...document.querySelectorAll<HTMLButtonElement>('button[role="option"]')]
.map((item) => item.textContent ?? "");
@@ -115,7 +166,7 @@ describe("composer assignee picker", () => {
environmentId: null,
provider: undefined,
});
await click("Select assignee, model and effort");
await click("Select model and effort");
await click("Choose exact model");
const choices = [...document.querySelectorAll<HTMLButtonElement>('button[role="option"]')]
.map((item) => item.textContent ?? "");
@@ -127,21 +178,48 @@ describe("composer assignee picker", () => {
const onAssigneeChange = vi.fn();
const onSettingsChange = vi.fn();
render(onAssigneeChange, onSettingsChange);
await click("Select assignee, model and effort");
await click("Choose assignee");
await click("Select assignee");
await click("Clippy");
expect(onAssigneeChange).not.toHaveBeenCalled();
expect(onSettingsChange).not.toHaveBeenCalled();
expect(document.querySelector('[role="listbox"]')).toBeNull();
});
it("offers No assignee and clears settings when selected", async () => {
const onAssigneeChange = vi.fn();
const onSettingsChange = vi.fn();
render(onAssigneeChange, onSettingsChange);
await click("Select assignee, model and effort");
await click("Choose assignee");
await click("Select assignee");
await click("No assignee");
expect(onAssigneeChange).toHaveBeenCalledWith("");
expect(onSettingsChange).toHaveBeenCalledWith({ model: null, effort: null, fast: false });
expect(onSettingsChange).toHaveBeenCalledWith(null);
expect(document.querySelector('[role="listbox"]')).toBeNull();
});
it.each(["", "user:me"])("hides model and effort for %s and opens assignees in one click", async (assigneeValue) => {
const loadModels = vi.spyOn(agentsApi, "adapterModels");
render(vi.fn(), vi.fn(), true, {
assigneeValue,
currentAssigneeValue: assigneeValue,
options: [...options, { id: "user:me", label: "Me" }],
});
expect(container!.querySelector('[data-testid="task-chat-composer-model-label"]')).toBeNull();
expect(container!.querySelector('[aria-label="Select model and effort"]')).toBeNull();
expect(container!.textContent).not.toContain("Harness default");
expect(container!.textContent).not.toContain("High");
await click("Select assignee");
expect(document.querySelector('[aria-label="Search assignees"]')).not.toBeNull();
expect(document.querySelector('[aria-label="Effort"]')).toBeNull();
expect(document.querySelector('[aria-label="Choose exact model"]')).toBeNull();
expect(document.body.textContent).not.toContain("Choose an agent");
expect(loadModels).not.toHaveBeenCalled();
});
it("opens the assignee list directly on mobile", async () => {
render(vi.fn(), vi.fn(), false, { mobile: true });
await click("Select assignee");
expect(document.querySelector('[data-testid="composer-mobile-dialog"]')).not.toBeNull();
expect(document.querySelector('[aria-label="Search assignees"]')).not.toBeNull();
expect(document.querySelector('[aria-label="Choose exact model"]')).toBeNull();
});
});
@@ -5,11 +5,12 @@ import type { Agent, IssueAssigneeAdapterOverrides } from "@paperclipai/shared";
import { agentsApi, type AdapterModel } from "@/api/agents";
import { queryKeys } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
import { getLastComposerEffort, rememberComposerEffort } from "@/lib/recent-composer-effort";
import type { InlineEntityOption } from "@/components/InlineEntitySelector";
import { Dialog, DialogClose, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
composerCatalogProvider, composerEfforts, composerFastAvailable, DEFAULT_COMPOSER_RUN_SETTINGS,
composerCatalogProvider, composerDefaultModel, composerEfforts, composerFastAvailable, DEFAULT_COMPOSER_RUN_SETTINGS,
EFFORT_LABELS, readComposerRunSettings, supportsComposerModel,
type ComposerRunSettings,
} from "./composer-run-settings";
@@ -23,7 +24,7 @@ interface Props {
agents: ReadonlyMap<string, Agent>;
overrides?: IssueAssigneeAdapterOverrides | null;
settings: ComposerRunSettings | null;
onSettingsChange: (settings: ComposerRunSettings) => void;
onSettingsChange: (settings: ComposerRunSettings | null) => void;
onAssigneeChange: (value: string) => void;
disabled?: boolean;
mobile?: boolean;
@@ -117,7 +118,7 @@ export function ComposerRunSettingsPicker({
? readComposerRunSettings(overrides, agent?.adapterType)
: DEFAULT_COMPOSER_RUN_SETTINGS;
const selected = settings ?? base;
const configuredModel = typeof agent?.adapterConfig.model === "string" ? agent.adapterConfig.model : "";
const configuredModel = composerDefaultModel(agent);
const model = selected.model ?? configuredModel;
const modelName = models.find((item) => item.id === model)?.label ?? model ?? "";
const choices = composerEfforts(agent, model, models.map((item) => item.id));
@@ -125,6 +126,15 @@ export function ComposerRunSettingsPicker({
const effortIndex = effort ? choices.indexOf(effort) + 1 : 0;
const effortLabel = effort ? EFFORT_LABELS[effort] ?? effort : "Default";
const fastAvailable = composerFastAvailable(agent, model);
const effortChoicesKey = choices.join(",");
useEffect(() => {
// Explicit drafts and task overrides take precedence over remembered effort.
if (settings !== null || base.effort || !modelSupported) return;
const remembered = getLastComposerEffort(companyId);
if (remembered && effortChoicesKey.split(",").includes(remembered)) {
onSettingsChange({ ...base, effort: remembered });
}
}, [companyId, agentId, model, modelSupported, settings, base.model, base.effort, base.fast, effortChoicesKey, onSettingsChange]);
const changed = Boolean(selected.model || selected.effort || selected.fast);
const assigneeOptions = [{ id: "", label: "No assignee", searchText: "Unassigned" }, ...options.filter((item) => item.id !== "")];
const filteredAgents = assigneeOptions.filter((item) => {
@@ -146,34 +156,50 @@ export function ComposerRunSettingsPicker({
useEffect(() => {
if (previousAgentId.current === agentId) return;
previousAgentId.current = agentId;
setView("settings");
setView((current) => current === "agents" ? "agents" : "settings");
setModelSearch("");
setAssigneeSearch("");
}, [agentId]);
const chooseAssignee = (value: string) => {
if (value !== assigneeValue) {
onAssigneeChange(value);
onSettingsChange(DEFAULT_COMPOSER_RUN_SETTINGS);
onSettingsChange(null);
}
setOpen(false);
setView("settings");
setAssigneeSearch("");
};
const chooseModel = (value: string | null) => {
onSettingsChange({ model: value, effort: null, fast: false });
const nextChoices = composerEfforts(agent, value ?? configuredModel, models.map((item) => item.id));
const preferred = selected.effort ?? getLastComposerEffort(companyId);
onSettingsChange({ model: value, effort: preferred && nextChoices.includes(preferred) ? preferred : null, fast: false });
setView("settings");
setModelSearch("");
};
const reset = () => onSettingsChange(DEFAULT_COMPOSER_RUN_SETTINGS);
const reset = () => {
rememberComposerEffort(companyId, null);
onSettingsChange(DEFAULT_COMPOSER_RUN_SETTINGS);
};
const closeButton = mobile ? <DialogClose asChild><button type="button" aria-label="Close picker" className="grid size-8 place-items-center rounded-md text-muted-foreground hover:bg-accent"><X className="size-4" /></button></DialogClose> : null;
const trigger = <button ref={triggerRef} type="button" disabled={disabled} aria-label="Select assignee, model and effort" data-testid="task-chat-composer-assignee"
className="flex h-8 min-w-0 max-w-full items-center gap-1.5 rounded-full bg-muted px-2.5 text-xs font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
{renderAssigneeIdentity?.(assigneeValue, agent?.name ?? "Unassigned", "trigger")}
<span data-testid="task-chat-composer-assignee-label" className="min-w-0 truncate">{assigneeOptions.find((item) => item.id === assigneeValue)?.label ?? "Unassigned"}</span>
<span className="text-muted-foreground" aria-hidden>·</span><span data-testid="task-chat-composer-model-label" className="min-w-0 truncate text-muted-foreground">{modelSupported ? modelName || "Harness default" : "Harness default"}</span>
{effort ? <span className="hidden shrink-0 text-muted-foreground sm:inline">{effortLabel}</span> : null}
<ChevronDown className="size-3 shrink-0 text-muted-foreground" aria-hidden />
</button>;
const Trigger = mobile ? DialogTrigger : PopoverTrigger;
const triggers = <div data-testid="task-chat-composer-selection" className="flex min-w-0 max-w-full items-center rounded-full bg-muted text-xs font-medium">
<Trigger asChild><button ref={triggerRef} type="button" disabled={disabled} aria-label="Select assignee" data-testid="task-chat-composer-assignee"
onClick={() => { setView("agents"); setHighlightedAssignee(0); }}
className="flex h-8 min-w-0 max-w-full items-center gap-1.5 rounded-full px-2.5 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
{renderAssigneeIdentity?.(assigneeValue, agent?.name ?? "Unassigned", "trigger")}
<span data-testid="task-chat-composer-assignee-label" className="min-w-0 truncate">{assigneeOptions.find((item) => item.id === assigneeValue)?.label ?? "Unassigned"}</span>
{!modelSupported ? <ChevronDown className="size-3 shrink-0 text-muted-foreground" aria-hidden /> : null}
</button></Trigger>
{modelSupported ? <>
<span className="text-muted-foreground" aria-hidden>·</span>
<button type="button" disabled={disabled} aria-label="Select model and effort" aria-haspopup="dialog" aria-expanded={open && view !== "agents"} onClick={() => { setView("settings"); setOpen(true); }}
className="flex h-8 min-w-0 max-w-full items-center gap-1.5 rounded-full px-2.5 text-muted-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
<span data-testid="task-chat-composer-model-label" className="min-w-0 truncate">{modelName || "Default"}</span>
{effort ? <span className="hidden shrink-0 sm:inline">{effortLabel}</span> : null}
<ChevronDown className="size-3 shrink-0" aria-hidden />
</button>
</> : null}
</div>;
const body = view === "settings" ? <div className="p-3">
<div className="flex items-center gap-2">
@@ -188,7 +214,7 @@ export function ComposerRunSettingsPicker({
</div>
{modelSupported ? <>
<button type="button" aria-label="Choose exact model" onClick={() => setView("models")} className="mt-3 flex w-full items-center gap-2 rounded-md px-1 py-1 text-left hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span className="min-w-0 flex-1"><span className="block text-xs text-muted-foreground">Model</span><span className="block truncate text-sm font-medium">{modelName || "Harness default"}</span></span><ChevronDown className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0 flex-1"><span className="block text-xs text-muted-foreground">Model</span><span className="block truncate text-sm font-medium">{modelName || "Default"}</span></span><ChevronDown className="size-4 shrink-0 text-muted-foreground" aria-hidden />
</button>
{choices.length ? <div className="mt-3">
<div className="flex items-center gap-2">
@@ -197,22 +223,29 @@ export function ComposerRunSettingsPicker({
<button type="button" aria-label="Reset to agent default" title="Reset to agent default" disabled={!changed} onClick={reset} className="grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent disabled:opacity-40"><RotateCcw className="size-4" /></button>
</div>
<input id="composer-run-effort" type="range" min={0} max={choices.length} step={1} value={effortIndex} aria-label={agent?.adapterType === "pi_local" ? "Thinking" : "Effort"} aria-valuetext={effortLabel}
onChange={(event) => onSettingsChange({ ...selected, effort: Number(event.target.value) === 0 ? null : choices[Number(event.target.value) - 1] })}
onChange={(event) => {
const nextEffort = Number(event.target.value) === 0 ? null : choices[Number(event.target.value) - 1]!;
rememberComposerEffort(companyId, nextEffort);
onSettingsChange({ ...selected, effort: nextEffort });
}}
className="composer-run-effort-range mt-3 w-full" style={{ "--fill": `${effortIndex / choices.length * 100}%` } as CSSProperties} />
</div> : null}
</> : <div className="mt-3 rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground" data-testid="model-unavailable">{unavailableModelReason(agent)}</div>}
</> : agent ? <div className="mt-3 rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground" data-testid="model-unavailable">{unavailableModelReason(agent)}</div> : null}
</div> : view === "agents" ? <div className="p-2">
<div className="flex items-center gap-2 px-1 py-1.5"><button type="button" aria-label="Back to selection" onClick={() => setView("settings")} className="grid size-7 place-items-center rounded-md hover:bg-accent"><ArrowLeft className="size-4" /></button><span className="min-w-0 flex-1"><span className="block text-xs font-semibold">Choose assignee</span><span className="block truncate text-xs text-muted-foreground">Each agent keeps its configured harness.</span></span>{closeButton}</div>
<div className="flex items-center gap-2 px-1 py-1.5"><span className="min-w-0 flex-1 text-xs font-semibold">Choose assignee</span>{closeButton}</div>
<div className="relative mt-2"><Search className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground" aria-hidden /><input autoFocus type="search" aria-label="Search assignees" placeholder="Search assignees…" value={assigneeSearch} onChange={(event) => { setAssigneeSearch(event.target.value); setHighlightedAssignee(0); }} onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); setHighlightedAssignee((current) => filteredAgents.length ? (current + (event.key === "ArrowDown" ? 1 : -1) + filteredAgents.length) % filteredAgents.length : 0); }
if (event.key === "Enter" && filteredAgents.length) { event.preventDefault(); chooseAssignee(filteredAgents[Math.min(highlightedAssignee, filteredAgents.length - 1)]!.id); }
}} className="h-9 w-full rounded-md border border-border bg-background pl-8 pr-2 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" /></div>
<div className="mt-2 max-h-60 overflow-y-auto" role="listbox" aria-label="Assignees">{filteredAgents.map((item, index) => <button key={item.id} type="button" role="option" aria-selected={item.id === assigneeValue} onMouseEnter={() => setHighlightedAssignee(index)} onClick={() => chooseAssignee(item.id)} className={cn("flex w-full items-center gap-2 rounded-md px-2 py-2 text-left focus-visible:outline-none", highlightedAssignee === index ? "bg-accent" : "hover:bg-accent")}>{renderAssigneeIdentity?.(item.id, item.label, "option")}<span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium">{item.label}</span><span className="block truncate text-xs text-muted-foreground">{agents.get(item.id.startsWith("agent:") ? item.id.slice(6) : "")?.role ?? ""}</span></span><span className="truncate text-xs text-muted-foreground">{harnessLabel(agents.get(item.id.startsWith("agent:") ? item.id.slice(6) : ""))}</span>{item.id === assigneeValue ? <Check className="composer-run-settings-accent size-4" /> : null}</button>)}{!filteredAgents.length ? <p className="px-2 py-2 text-xs text-muted-foreground">No matches.</p> : null}</div>
<div className="mt-2 max-h-60 overflow-y-auto" role="listbox" aria-label="Assignees">{filteredAgents.map((item, index) => {
const optionAgent = agents.get(item.id.startsWith("agent:") ? item.id.slice(6) : "");
return <button key={item.id} type="button" role="option" aria-selected={item.id === assigneeValue} onMouseEnter={() => setHighlightedAssignee(index)} onClick={() => chooseAssignee(item.id)} className={cn("flex w-full items-center gap-2 rounded-md px-2 py-2 text-left focus-visible:outline-none", highlightedAssignee === index ? "bg-accent" : "hover:bg-accent")}>{renderAssigneeIdentity?.(item.id, item.label, "option")}<span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium">{item.label}</span>{optionAgent ? <span className="block truncate text-xs text-muted-foreground">{optionAgent.role}</span> : null}</span>{optionAgent ? <span className="truncate text-xs text-muted-foreground">{harnessLabel(optionAgent)}</span> : null}{item.id === assigneeValue ? <Check className="composer-run-settings-accent size-4" /> : null}</button>;
})}{!filteredAgents.length ? <p className="px-2 py-2 text-xs text-muted-foreground">No matches.</p> : null}</div>
</div> : <div className="p-2">
<div className="flex items-center gap-2 px-1 py-1.5"><button type="button" aria-label="Back to selection" onClick={() => setView("settings")} className="grid size-7 place-items-center rounded-md hover:bg-accent"><ArrowLeft className="size-4" /></button><span className="min-w-0 flex-1"><span className="block text-xs font-semibold">Choose model</span><span className="block truncate text-xs text-muted-foreground">{harnessLabel(agent)}</span></span>{closeButton}</div>
<div className="relative mt-2"><Search className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground" aria-hidden /><input autoFocus type="search" aria-label="Search or paste a model ID" placeholder="Search or paste a model ID" value={modelSearch} onChange={(event) => setModelSearch(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && manualValid && !exactMatch) chooseModel(query); }} className="h-9 w-full rounded-md border border-border bg-background pl-8 pr-2 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" /></div>
<div className="mt-2 max-h-60 overflow-y-auto" role="listbox" aria-label="Models">
{!query ? <button type="button" role="option" aria-selected={selected.model === null} onClick={() => chooseModel(null)} className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left hover:bg-accent"><span className="min-w-0 flex-1"><span className="block text-sm font-medium">Use agent default</span><span className="block truncate text-xs text-muted-foreground">{models.find((item) => item.id === configuredModel)?.label || configuredModel || "Harness default"}</span></span>{selected.model === null ? <Check className="composer-run-settings-accent size-4" /> : null}</button> : null}
{!query ? <button type="button" role="option" aria-selected={selected.model === null} onClick={() => chooseModel(null)} className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left hover:bg-accent"><span className="min-w-0 flex-1"><span className="block text-sm font-medium">Use agent default</span><span className="block truncate text-xs text-muted-foreground">{models.find((item) => item.id === configuredModel)?.label || configuredModel || "Default"}</span></span>{selected.model === null ? <Check className="composer-run-settings-accent size-4" /> : null}</button> : null}
{filteredModels.map((item) => <button type="button" role="option" aria-selected={selected.model === item.id} key={item.id} onClick={() => chooseModel(item.id)} className="flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-left text-sm transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"><span className="min-w-0 flex-1"><span className="block truncate font-medium">{item.label}</span><span className="block truncate font-mono text-xs text-muted-foreground">{item.id}</span></span>{item.detail || item.id === configuredModel ? <span className="shrink-0 text-xs text-muted-foreground">{item.detail ?? "Agent default"}</span> : null}{selected.model === item.id ? <Check className="composer-run-settings-accent size-4 shrink-0" /> : null}</button>)}
{catalogPending ? <p className="px-2.5 py-2 text-xs text-muted-foreground">Loading models…</p> : null}
{!catalogPending && !filteredModels.length && query ? <p className="px-2.5 py-2 text-xs text-muted-foreground">No catalog match.</p> : null}
@@ -222,6 +255,6 @@ export function ComposerRunSettingsPicker({
</div>;
const onOpenChange = (next: boolean) => { setOpen(next); if (!next) { setView("settings"); setModelSearch(""); setAssigneeSearch(""); } };
return mobile ? <Dialog open={open} onOpenChange={onOpenChange}><DialogTrigger asChild>{trigger}</DialogTrigger><DialogContent aria-describedby={undefined} showCloseButton={false} className="composer-mobile-dialog top-(--pct-50) -translate-y-(--pct-50) gap-0 overflow-y-auto p-0" data-testid="composer-mobile-dialog"><DialogTitle className="sr-only">Select assignee, model and effort</DialogTitle><AnimatedBody>{body}</AnimatedBody></DialogContent></Dialog>
: <Popover open={open} onOpenChange={onOpenChange}><PopoverTrigger asChild>{trigger}</PopoverTrigger><PopoverContent side="top" align="end" sideOffset={8} className="w-80 max-w-full p-0 shadow-sm" data-testid="composer-model-popover"><AnimatedBody>{body}</AnimatedBody></PopoverContent></Popover>;
return mobile ? <Dialog open={open} onOpenChange={onOpenChange}>{triggers}<DialogContent aria-describedby={undefined} showCloseButton={false} className="composer-mobile-dialog top-(--pct-50) -translate-y-(--pct-50) gap-0 overflow-y-auto p-0" data-testid="composer-mobile-dialog"><DialogTitle className="sr-only">{view === "agents" ? "Select assignee" : "Select model and effort"}</DialogTitle><AnimatedBody>{body}</AnimatedBody></DialogContent></Dialog>
: <Popover open={open} onOpenChange={onOpenChange}><PopoverAnchor asChild>{triggers}</PopoverAnchor><PopoverContent side="top" align="end" sideOffset={8} className="w-80 max-w-full p-0 shadow-sm" data-testid="composer-model-popover"><AnimatedBody>{body}</AnimatedBody></PopoverContent></Popover>;
}
@@ -0,0 +1,109 @@
import { useMemo, type CSSProperties } from "react";
import { Folder, GitBranch, GitBranchPlus, Monitor } from "lucide-react";
import type { ExecutionWorkspaceSummary } from "@paperclipai/shared";
import { SearchableSelect, type SearchableSelectOption } from "@/components/SearchableSelect";
import { orderReusableExecutionWorkspaces } from "@/lib/reusable-execution-workspaces";
interface WorktreeOption extends SearchableSelectOption {
mode: string;
workspaceId?: string;
description: string;
}
interface ComposerWorktreePickerProps {
mode: string;
workspaceId: string;
workspaces: readonly ExecutionWorkspaceSummary[];
onChange: (mode: string, workspaceId: string) => void;
loading: boolean;
error: boolean;
onRetry: () => void;
disabled?: boolean;
mobile?: boolean;
contentStyle?: CSSProperties;
selectedWorkspaceLabel?: string;
}
const MODE_OPTIONS: WorktreeOption[] = [
{ key: "isolated_workspace", value: "isolated_workspace", mode: "isolated_workspace", label: "New worktree", description: "Start in an isolated checkout." },
{ key: "shared_workspace", value: "shared_workspace", mode: "shared_workspace", label: "Project workspace", description: "Use the project's shared checkout." },
{ key: "operator_branch", value: "operator_branch", mode: "operator_branch", label: "New branch", description: "Create a branch in the project workspace." },
{ key: "agent_default", value: "agent_default", mode: "agent_default", label: "Agent default", description: "Let the agent choose its workspace." },
{ key: "inherit", value: "inherit", mode: "inherit", label: "Project default", description: "Use the project's worktree settings." },
];
/** Reuses the searchable selector and the existing workspace recency ordering. */
export function ComposerWorktreePicker({
mode, workspaceId, workspaces, onChange, loading, error, onRetry,
disabled, mobile, contentStyle, selectedWorkspaceLabel,
}: ComposerWorktreePickerProps) {
const groups = useMemo(() => [
{ id: "new", options: [MODE_OPTIONS[0]!] },
{
id: "reuse", label: "Reuse a worktree",
options: orderReusableExecutionWorkspaces(workspaces).map((workspace): WorktreeOption => ({
key: `reuse:${workspace.id}`, value: `reuse:${workspace.id}`,
workspaceId: workspace.id, mode: "reuse_existing", label: workspace.name,
description: workspace.branchName ?? "Existing workspace",
searchText: `${workspace.branchName ?? ""} ${workspace.id}`,
})),
},
{
id: "other", label: "Other options",
options: MODE_OPTIONS.filter((option) => option.mode === "shared_workspace"
|| (option.mode !== "isolated_workspace" && option.mode === mode)),
},
], [mode, workspaces]);
const value = mode === "reuse_existing" ? `reuse:${workspaceId}` : mode;
return (
<SearchableSelect<string, WorktreeOption>
value={value}
groups={groups}
onValueChange={(_, option) => onChange(option.mode, option.workspaceId ?? "")}
placeholder="Worktrees"
triggerAriaLabel="Worktrees"
mobileTitle="Worktrees"
searchPlaceholder="Search worktrees..."
emptyMessage="No matching worktrees."
disabled={disabled}
modal={mobile}
contentStyle={contentStyle}
contentWidth="auto"
className="min-w-0 flex-1 sm:flex-none sm:max-w-72"
triggerClassName="h-8 gap-1.5 border-0 bg-transparent px-2 text-xs font-medium shadow-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-0"
renderValue={(option) => (
<span className="flex min-w-0 items-center gap-1.5">
<GitBranch className="size-3.5 shrink-0" aria-hidden />
<span className="truncate">
<span className="hidden text-muted-foreground sm:inline">Worktrees · </span>
{option?.label ?? selectedWorkspaceLabel ?? "Select worktree"}
</span>
</span>
)}
renderOption={(option) => {
const Icon = option.workspaceId ? GitBranch : option.mode === "isolated_workspace" ? GitBranchPlus
: option.mode === "agent_default" ? Monitor : option.mode === "operator_branch" ? GitBranch : Folder;
return (
<>
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
<span className="truncate text-xs text-muted-foreground">{option.description}</span>
</span>
</>
);
}}
listFooter={loading || error || workspaces.length === 0 ? (
<div className="border-t border-border px-3 py-2 text-xs text-muted-foreground" role="status">
{loading ? "Loading existing worktrees…" : error ? (
<span className="flex items-center justify-between gap-2">
Couldn't load worktrees.
<button type="button" className="rounded px-2 py-1 text-foreground hover:bg-accent" onClick={onRetry}>Retry</button>
</span>
) : "No existing worktrees yet. Choose New worktree to start one."}
</div>
) : undefined}
/>
);
}
@@ -297,6 +297,54 @@ function autocompleteOption(matchText: string) {
}
describe("TaskChatComposer", () => {
it("keeps creation images inline and prevents submission during their upload", async () => {
let resolveUpload!: (url: string) => void;
const onImageUpload = vi.fn().mockReturnValue(new Promise<string>((resolve) => { resolveUpload = resolve; }));
const creation = {
value: "Before the screenshot",
onChange: vi.fn(),
onSubmit: vi.fn().mockResolvedValue(undefined),
submitLabel: "Create task",
onSelectFiles: vi.fn(),
runSettings: null,
onRunSettingsChange: vi.fn(),
};
render(<TaskChatComposer creation={creation} workMode="standard" onImageUpload={onImageUpload} />);
const image = new File(["png"], "screen.png", { type: "image/png" });
expect(pasteFiles([image]).defaultPrevented).toBe(false);
expect(creation.onSelectFiles).not.toHaveBeenCalled();
const handler = mdxEditorMockState.imagePluginOptions!.imageUploadHandler!;
const upload = handler(image);
await flushAsync();
expect(sendButton().disabled).toBe(true);
pressKey("Enter", { metaKey: true });
expect(creation.onSubmit).not.toHaveBeenCalled();
resolveUpload("/api/assets/screen/content");
const url = await upload;
const value = `Before the screenshot\n\n![screen](${url})\n\nAfter the screenshot`;
render(<TaskChatComposer creation={{ ...creation, value }} workMode="standard" onImageUpload={onImageUpload} />);
await flushAsync();
flushSync(() => sendButton().click());
await flushAsync();
expect(creation.onSubmit).toHaveBeenCalledWith(value, "standard", null);
expect(onImageUpload).toHaveBeenCalledWith(image);
});
it("stages non-image creation paste files while images reach the editor", () => {
const creation = {
value: "Inspect these files",
onChange: vi.fn(), onSubmit: vi.fn().mockResolvedValue(undefined),
submitLabel: "Create task", onSelectFiles: vi.fn(),
runSettings: null, onRunSettingsChange: vi.fn(),
};
render(<TaskChatComposer creation={creation} workMode="standard" onImageUpload={vi.fn()} />);
const image = new File(["png"], "screen.png", { type: "image/png" });
const document = new File(["notes"], "notes.txt", { type: "text/plain" });
expect(pasteFiles([image, document]).defaultPrevented).toBe(false);
expect(creation.onSelectFiles).toHaveBeenCalledWith([document]);
expect(pasteFiles([document]).defaultPrevented).toBe(true);
});
it("settles an acknowledged submission after navigating away", async () => {
const key = "navigate-before-save";
let resolveSend!: () => void;
+105 -20
View File
@@ -49,6 +49,7 @@ import {
type MarkdownEditorRef,
} from "@/components/MarkdownEditor";
import { nextWorkMode } from "@/lib/work-mode-meta";
import { trackRecentAssignee, trackRecentAssigneeUser } from "@/lib/recent-assignees";
import {
InlineEntitySelector,
type InlineEntityOption,
@@ -62,6 +63,7 @@ import { TaskChatComposerTakeoverActionsContext } from "./TaskChatComposerTakeov
import { TaskChatPausedTakeover, type TaskComposerPause } from "./TaskChatPausedTakeover";
import { ComposerRunSettingsPicker } from "./ComposerRunSettingsPicker";
import { TaskChatComposerBar } from "./TaskChatComposerBar";
import { ComposerAddMenu, ComposerModeChip } from "./ComposerAddMenu";
import type { ComposerRunSettings } from "./composer-run-settings";
import type { Agent, IssueAssigneeAdapterOverrides } from "@paperclipai/shared";
@@ -89,7 +91,7 @@ export interface TaskChatComposerTakeover {
}
interface TaskChatComposerProps {
onAdd: (
onAdd?: (
body: string,
reopen?: boolean,
reassignment?: CommentReassignment,
@@ -97,6 +99,21 @@ interface TaskChatComposerProps {
clientRequestId?: string,
runSettings?: ComposerRunSettings,
) => Promise<void> | void;
/** New tasks share the editor and controls, but retain their draft until creation succeeds. */
creation?: {
value: string;
onChange: (value: string) => void;
onSubmit: (body: string, mode: IssueWorkMode, settings: ComposerRunSettings | null) => Promise<void>;
submitLabel: string;
canSubmitWithoutBody?: boolean;
header?: ReactNode;
details?: ReactNode;
contextBar?: ReactNode;
submitDisabled?: boolean;
onSelectFiles: (files: File[]) => void;
runSettings: ComposerRunSettings | null;
onRunSettingsChange: (settings: ComposerRunSettings | null) => void;
};
confirmedSubmissionIds?: ReadonlySet<string>;
onStop?: () => Promise<void>;
stopPending?: boolean;
@@ -358,6 +375,7 @@ function escapeMarkdownLabel(name: string): string {
*/
export function TaskChatComposer({
onAdd,
creation,
confirmedSubmissionIds,
onStop,
stopPending = false,
@@ -395,7 +413,8 @@ export function TaskChatComposer({
}: TaskChatComposerProps) {
const streamlined = useStreamlinedTaskChatPresentation();
const stopControl = useComposerStop(onStop, stopPending);
const [body, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : ""));
const [localBody, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : ""));
const body = creation ? creation.value : localBody;
const [submitting, setSubmitting] = useState(false);
const [reviewError, setReviewError] = useState(false);
const [uncertainSubmission, setUncertainSubmission] =
@@ -417,10 +436,13 @@ export function TaskChatComposer({
useState<HTMLElement | null>(null);
const [takeoverControlsSlot, setTakeoverControlsSlot] =
useState<HTMLElement | null>(null);
const [pendingMode, setPendingMode] = useState<IssueWorkMode>(workMode);
const [localPendingMode, setPendingMode] = useState<IssueWorkMode>(workMode);
const pendingMode = creation ? workMode : localPendingMode;
const [pendingAssignee, setPendingAssignee] = useState<string | null>(null);
const [runSettings, setRunSettings] = useState<ComposerRunSettings | null>(null);
useEffect(() => setRunSettings(null), [draftKey, currentAssigneeValue]);
const [localRunSettings, setLocalRunSettings] = useState<ComposerRunSettings | null>(null);
const runSettings = creation ? creation.runSettings : localRunSettings;
const setRunSettings = creation ? creation.onRunSettingsChange : setLocalRunSettings;
useEffect(() => setLocalRunSettings(null), [draftKey, currentAssigneeValue]);
const [actionError, setActionError] = useState<string | null>(null);
const [attachments, setAttachmentState] = useState<ComposerAttachment[]>(
() =>
@@ -465,6 +487,10 @@ export function TaskChatComposer({
} | null>(null);
function changeBody(value: string) {
bodyRef.current = value;
if (creation) {
creation.onChange(value);
return;
}
setBody(value);
const pending = pendingDraftRef.current;
if (!pending || pending.draftKey !== draftKey ||
@@ -607,11 +633,11 @@ export function TaskChatComposer({
!pause &&
!queuedEdit &&
!uncertainSubmission &&
Boolean(onAttachImage || onImageUpload);
Boolean(creation || onAttachImage || onImageUpload);
const showAssignee = Boolean(
enableReassign && reassignOptions && reassignOptions.length > 0,
);
const assigneeValue = pendingAssignee ?? currentAssigneeValue;
const assigneeValue = creation ? currentAssigneeValue : pendingAssignee ?? currentAssigneeValue;
const assigneeLabel =
reassignOptions?.find((o) => o.id === assigneeValue)?.label ?? "Unassigned";
const assigneeName =
@@ -637,10 +663,20 @@ export function TaskChatComposer({
};
function updatePendingAssignee(value: string | null) {
if (!creation && value && companyId) {
const selection = parseAssigneeValue(value);
if (selection?.assigneeAgentId) trackRecentAssignee(selection.assigneeAgentId, companyId);
if (selection?.assigneeUserId) trackRecentAssigneeUser(selection.assigneeUserId, companyId);
}
setPendingAssignee(value);
onPendingAssigneeChange?.(value);
}
function changeMode(mode: IssueWorkMode) {
setPendingMode(mode);
if (creation) void onWorkModeChange?.(mode);
}
/** Upload an image and return its URL for inline `![](src)` markdown. */
async function uploadInlineImage(file: File): Promise<string> {
const id = crypto.randomUUID();
@@ -764,6 +800,11 @@ export function TaskChatComposer({
function handleFileInputChange(evt: ChangeEvent<HTMLInputElement>) {
const files = evt.target.files;
if (files && files.length > 0) {
if (creation) {
creation.onSelectFiles(Array.from(files));
evt.target.value = "";
return;
}
void (async () => {
for (const file of Array.from(files)) await attachPickedFile(file);
})();
@@ -787,6 +828,19 @@ export function TaskChatComposer({
if (pause || !canAcceptFiles) return;
const files = Array.from(evt.clipboardData?.files ?? []);
if (files.length === 0) return;
if (creation) {
if (onImageUpload) {
const nonImages = files.filter((file) => !file.type.startsWith("image/"));
if (nonImages.length === 0) return;
creation.onSelectFiles(nonImages);
if (nonImages.length !== files.length) return;
} else {
creation.onSelectFiles(files);
}
evt.preventDefault();
evt.stopPropagation();
return;
}
const nonImages = files.filter((file) => !file.type.startsWith("image/"));
if (nonImages.length === 0) return;
if (nonImages.length === files.length) {
@@ -830,6 +884,22 @@ export function TaskChatComposer({
async function submit() {
if (disabled || (pause && !canResetPausedConversation)) return;
if (creation) {
if (submittingRef.current || uploadPending || uploadFailed || creation.submitDisabled || (!bodyRef.current.trim() && !creation.canSubmitWithoutBody)) return;
submittingRef.current = true;
setSubmitting(true);
setActionError(null);
try {
await creation.onSubmit(bodyRef.current, pendingMode, runSettings);
} catch (error) {
setActionError(error instanceof Error ? error.message : "Failed to create task. Try again.");
} finally {
submittingRef.current = false;
setSubmitting(false);
}
return;
}
if (!onAdd) return;
const retained =
draftKey && !queuedEdit ? loadDraftSubmission(draftKey) : null;
if (retained && !submitting) {
@@ -1116,7 +1186,7 @@ export function TaskChatComposer({
}
return (
<div className="flex min-w-0 flex-col gap-2">
<div className={cn("flex min-w-0 flex-col", creation?.contextBar ? "gap-0" : "gap-2")}>
{takeoverVisible && takeover ? (
<section
className={cn(
@@ -1180,6 +1250,16 @@ export function TaskChatComposer({
) : null}
</section>
) : null}
{creation?.contextBar ? (
<TaskChatComposerBar
className="flex h-11 min-w-0 items-center gap-2 px-2.5"
data-testid="task-chat-composer-context"
role="group"
aria-label="Task project and worktrees"
>
{creation.contextBar}
</TaskChatComposerBar>
) : null}
<div
className={cn(
streamlined
@@ -1200,11 +1280,12 @@ export function TaskChatComposer({
if (isModeShortcut) {
e.preventDefault();
e.stopPropagation();
setPendingMode((mode) => nextWorkMode(mode));
changeMode(nextWorkMode(pendingMode));
}
}}
onPasteCapture={handlePasteCapture}
>
{creation?.header}
{uncertainSubmission ? (
<div
role="alert"
@@ -1275,18 +1356,18 @@ export function TaskChatComposer({
? (disabledReason ?? "Composer disabled")
: effectivePlaceholder
}
readOnly={disabled || !!uncertainSubmission}
readOnly={disabled || !!uncertainSubmission || Boolean(creation && submitting)}
mentions={mentions}
actionCommands={conversationMode ? [{
actionCommands={creation ? [] : conversationMode ? [{
id: "action:new", kind: "action", command: "new", name: "New session",
description: "Start fresh context here, preserving conversation history.", aliases: ["new"],
disabled,
}] : [goalCommandOption]}
onSubmit={() => void submit()}
imageUploadHandler={
canAcceptFiles ? uploadInlineImage : undefined
canAcceptFiles && (!creation || onImageUpload) ? uploadInlineImage : undefined
}
onDropFile={canAcceptFiles ? attachNonImageFile : undefined}
onDropFile={canAcceptFiles && !creation ? attachNonImageFile : undefined}
bordered={false}
className={cn(disabled && "opacity-60")}
contentClassName={
@@ -1301,7 +1382,7 @@ export function TaskChatComposer({
<p
className="px-1 text-xs text-destructive"
role="alert"
data-testid="task-chat-goal-error"
data-testid={creation ? "task-chat-create-error" : "task-chat-goal-error"}
>
{actionError}
</p>
@@ -1368,17 +1449,19 @@ export function TaskChatComposer({
</AttachmentGroup>
) : null}
{creation?.details}
<div
className={cn("mt-2 flex items-center gap-x-2 gap-y-3", mobile && !queuedEdit ? "flex-nowrap" : "flex-wrap")}
data-testid="task-chat-composer-actions"
>
<div className="flex min-w-0 max-w-full items-center gap-2">
{canAcceptFiles ? (
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileInputChange} />
<input ref={fileInputRef} type="file" className="hidden" multiple={Boolean(creation)} onChange={handleFileInputChange} />
) : null}
<ComposerAddMenu
mode={pendingMode}
onModeChange={!queuedEdit && onWorkModeChange ? setPendingMode : undefined}
onModeChange={!queuedEdit && onWorkModeChange ? changeMode : undefined}
onAttachFile={canAcceptFiles ? () => fileInputRef.current?.click() : undefined}
onGoal={!queuedEdit && !conversationMode && attachments.length === 0 &&
runnerGoalCapability?.availability === "available" && onRunnerGoalCommand
@@ -1395,7 +1478,7 @@ export function TaskChatComposer({
: "Editing queued message"}
</span>
) : (
<ComposerModeChip mode={pendingMode} onRemove={onWorkModeChange ? () => setPendingMode("standard") : undefined}
<ComposerModeChip mode={pendingMode} onRemove={onWorkModeChange ? () => changeMode("standard") : undefined}
disabled={disabled || !!uncertainSubmission} testId="task-chat-composer-mode" mobile={mobile} />
)}
</div>
@@ -1495,7 +1578,8 @@ export function TaskChatComposer({
!!uncertainSubmission ||
uploadPending ||
uploadFailed ||
(body.trim().length === 0 && attachedRefs.length === 0)
Boolean(creation?.submitDisabled) ||
(body.trim().length === 0 && attachedRefs.length === 0 && !creation?.canSubmitWithoutBody)
}
title={
showStop
@@ -1510,7 +1594,7 @@ export function TaskChatComposer({
? "Waiting for upload to finish"
: uploadFailed
? "Remove the failed attachment to send"
: "Send (⌘+Enter)"
: creation ? `${creation.submitLabel} (⌘+Enter)` : "Send (⌘+Enter)"
}
aria-label={
showStop
@@ -1521,8 +1605,9 @@ export function TaskChatComposer({
? queuedEdit.stale
? "Queue as new message"
: "Save queued message"
: "Send"
: creation?.submitLabel ?? "Send"
}
aria-busy={submitting}
className={cn(
"flex size-8 min-h-8 min-w-8 shrink-0 aspect-square items-center justify-center rounded-full transition-transform hover:scale-105 disabled:scale-100",
streamlined
@@ -0,0 +1,15 @@
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
/** The inset strip shared by queued messages and new-task context. */
export function TaskChatComposerBar({ className, ...props }: ComponentProps<"div">) {
return (
<div
className={cn(
"relative z-0 mx-3 -mb-px overflow-hidden rounded-t-xl rounded-b-none border border-b-0 border-border/75 bg-card shadow-sm",
className,
)}
{...props}
/>
);
}
@@ -29,6 +29,7 @@ import type {
IssueQueuedCommentQueue,
} from "@paperclipai/shared";
import { cn } from "@/lib/utils";
import { TaskChatComposerBar } from "./TaskChatComposerBar";
import {
DropdownMenu,
DropdownMenuContent,
@@ -377,8 +378,7 @@ export function TaskChatQueuedMessages({
if (entries.length === 0 && !visibleError) return null;
return (
<div
className="relative z-0 mx-3 -mb-px overflow-hidden rounded-t-xl rounded-b-none border border-b-0 border-border/75 bg-card shadow-sm"
<TaskChatComposerBar
data-testid="task-chat-queued-messages"
aria-label="Queued messages"
>
@@ -433,6 +433,6 @@ export function TaskChatQueuedMessages({
<div className="sr-only" aria-live="polite" aria-atomic="true">
{announcement}
</div>
</div>
</TaskChatComposerBar>
);
}
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@paperclipai/shared";
import {
composerCatalogProvider, composerEfforts, composerFastAvailable, mergeComposerRunSettings,
composerCatalogProvider, composerDefaultModel, composerEfforts, composerFastAvailable, mergeComposerRunSettings,
readComposerRunSettings, supportsComposerModel,
} from "./composer-run-settings";
@@ -10,6 +10,19 @@ const agent = (adapterType: Agent["adapterType"], provider?: string) => ({
}) as Agent;
describe("composer run settings", () => {
it("resolves known adapter defaults without guessing local CLI settings", () => {
expect(composerDefaultModel(agent("claude_local"))).toBe("");
expect(composerDefaultModel({ ...agent("claude_local"), adapterConfig: { model: "claude-opus-5" } })).toBe("claude-opus-5");
expect(composerDefaultModel({ ...agent("claude_local"), adapterConfig: { env: { ANTHROPIC_MODEL: "claude-sonnet-5" } } })).toBe("claude-sonnet-5");
expect(composerDefaultModel({ ...agent("claude_local"), adapterConfig: { env: { CLAUDE_CODE_USE_BEDROCK: "1" } } })).toBe("");
expect(composerDefaultModel({ ...agent("claude_local"), adapterConfig: { env: { CLAUDE_CODE_USE_VERTEX: "1" } } })).toBe("");
expect(composerDefaultModel({ ...agent("claude_local"), adapterConfig: { env: { ANTHROPIC_MODEL: { type: "secret_ref", secretId: "model-secret" } } } })).toBe("");
expect(composerDefaultModel({ ...agent("codex_local"), adapterConfig: { model: " private-codex " } })).toBe("private-codex");
expect(composerDefaultModel(agent("codex_local"))).toBe("");
expect(composerDefaultModel(agent("paperclip_runner", "codex"))).toBe("gpt-5.6-sol");
expect(composerDefaultModel(agent("grok_local"))).toBe("grok-build");
expect(composerDefaultModel(undefined)).toBe("");
});
it("shows only effort levels known for the selected harness and model", () => {
expect(composerEfforts(agent("codex_local"), "gpt-6-astra", [])).toContain("ultra");
expect(composerEfforts(agent("paperclip_runner", "codex"), "gpt-6-astra", [])).toContain("ultra");
@@ -1,5 +1,6 @@
import { codexLocalReasoningEffortsForModel, isCodexLocalFastModeSupported, isCodexLocalKnownModel } from "@paperclipai/adapter-codex-local";
import { claudeLocalReasoningEffortsForModel } from "@paperclipai/adapter-claude-local";
import { resolvePaperclipRunnerModel } from "@paperclipai/adapter-utils";
import { DEFAULT_GROK_LOCAL_MODEL, grokLocalReasoningEffortsForModel } from "@paperclipai/adapter-grok-local";
import { DEFAULT_KIMI_LOCAL_MODEL, modelSupportsEffort, KIMI_SUPPORTED_EFFORTS } from "@paperclipai/adapter-kimi-local";
import { aiConnectionBindingSchema, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared";
@@ -25,6 +26,29 @@ export function supportsComposerModel(agent: Agent | undefined): boolean {
return Boolean(agent && MODEL_ADAPTERS.has(agent.adapterType));
}
/** Resolve visible defaults; host environment and a local CLI's own config are unknown. */
export function composerDefaultModel(agent: Agent | undefined): string {
if (!agent) return "";
const config = agent.adapterConfig;
const configured = typeof config.model === "string" ? config.model.trim() : "";
if (configured) return configured;
if (agent.adapterType === "claude_local") {
const env = config.env && typeof config.env === "object" && !Array.isArray(config.env)
? config.env as Record<string, unknown> : {};
// The server merges host env too, which may select a different model or
// Bedrock/Vertex. Without an explicit agent setting, the default is unknown.
return typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL.trim() : "";
}
if (agent.adapterType === "grok_local") return DEFAULT_GROK_LOCAL_MODEL;
if (agent.adapterType === "kimi_local") return DEFAULT_KIMI_LOCAL_MODEL;
if (agent.adapterType === "paperclip_runner") {
const provider = composerCatalogProvider(agent);
if (provider === "codex" || provider === "opencode") return resolvePaperclipRunnerModel(provider, config.model);
if (provider === "acpx" && config.acpxAgent !== "grok") return resolvePaperclipRunnerModel(provider, config.model);
}
return "";
}
export function composerCatalogProvider(agent: Agent | undefined): string | undefined {
if (!agent) return undefined;
if (agent.adapterType === "paperclip_runner") return String(agent.adapterConfig.provider ?? "codex");
+11 -14
View File
@@ -1808,7 +1808,7 @@ select:where(:not([multiple]):not(.appearance-none)):where(:not([size]), [size="
align-items: center;
gap: 0.25rem;
margin: 0 0.1rem;
padding: 0 0.625rem;
padding: 2px 0.625rem;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 0.75rem;
@@ -1847,17 +1847,12 @@ a.paperclip-mention-chip[data-mention-kind="project"]::before {
.paperclip-mdxeditor-content a.paperclip-mention-chip[data-mention-kind="agent"]::before,
a.paperclip-mention-chip[data-mention-kind="agent"]::before {
width: 0.75rem;
height: 0.75rem;
background-color: currentColor;
-webkit-mask-image: var(--paperclip-mention-icon-mask);
mask-image: var(--paperclip-mention-icon-mask);
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: contain;
mask-size: contain;
width: 1rem;
height: 1rem;
background-image: var(--paperclip-mention-avatar-image);
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
.paperclip-mdxeditor-content ul,
@@ -2404,7 +2399,7 @@ span.paperclip-project-mention-chip {
align-items: center;
gap: 0.25rem;
margin: 0 0.1rem;
padding: 0 0.625rem;
padding: 2px 0.625rem;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 0.75rem;
@@ -3163,7 +3158,9 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
position: absolute;
inset: 0;
background: var(--mobile-entity-picker-backdrop);
pointer-events: none;
/* Hit the popper wrapper so Radix dismisses this sheet, while the parent
task dialog recognizes the tap as belonging to its nested picker. */
pointer-events: auto;
}
[data-mobile-entity-picker] {
@@ -47,4 +47,29 @@ describe("getMentionAwareLinkNodeInit", () => {
expect(created).toBeInstanceOf(MentionAwareLinkNode);
});
it.each([
"agent://agent-123?i=code",
"project://project-123?c=0f766e",
"user://user-123",
"skill://skill-123?s=test-it-for-real",
"routine://routine-123",
"/issues/PAP-123",
])("preserves the rich-chip destination %s", (href) => {
const editor = createTestEditor();
let renderedUrl: string | undefined;
editor.update(() => {
renderedUrl = $createLinkNode(href).sanitizeUrl(href);
});
expect(renderedUrl).toBe(href);
});
it("still blocks executable link destinations", () => {
const editor = createTestEditor();
let renderedUrl: string | undefined;
editor.update(() => {
renderedUrl = $createLinkNode("javascript:alert(1)").sanitizeUrl("javascript:alert(1)");
});
expect(renderedUrl).toBe("about:blank");
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ import {
type SerializedLinkNode,
} from "@lexical/link";
const CUSTOM_MENTION_URL_RE = /^(agent|project|skill):\/\//;
const CUSTOM_MENTION_URL_RE = /^(agent|project|user|skill|routine):\/\//;
export class MentionAwareLinkNode extends LinkNode {
static getType(): string {
+26 -15
View File
@@ -1,20 +1,31 @@
import { describe, expect, it, vi } from "vitest";
import { mentionChipInlineStyle } from "./mention-chips";
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { appearanceForPalette, agentAvatarUrl, legacyAgentAppearance } from "@paperclipai/shared";
import { applyMentionChipDecoration, clearMentionChipDecoration, mentionChipInlineStyle, parseMentionChipHref } from "./mention-chips";
const getAgentIcon = vi.hoisted(() => vi.fn());
vi.mock("./agent-icons", () => ({ getAgentIcon }));
describe("agent mention avatars", () => {
it("uses the agent's selected appearance instead of the saved legacy icon", () => {
const appearance = appearanceForPalette("electric-grove");
const style = mentionChipInlineStyle({ kind: "agent", agentId: "agent", icon: "code", appearance });
expect(style).toEqual({ "--paperclip-mention-avatar-image": `url("${agentAvatarUrl(appearance, 16, 2)}")` });
});
describe("agent mention icon compatibility", () => {
const node = [["path", { d: "m16 18 6-6-6-6", key: "code" }]];
it("keeps old icon-bearing mentions readable with the agent's stable default avatar", () => {
const parsed = parseMentionChipHref("agent://agent-old?icon=crown");
expect(parsed?.kind).toBe("agent");
expect(mentionChipInlineStyle(parsed!)).toEqual({
"--paperclip-mention-avatar-image": `url("${agentAvatarUrl(legacyAgentAppearance("agent-old"), 16, 2)}")`,
});
});
it.each([
["static", { iconNode: node }],
["legacy", { render: () => ({ props: { iconNode: node } }) }],
["icon-data", { render: () => ({ props: { icon: { name: "code", size: 24, node } } }) }],
])("renders an agent mention mask from Lucide's %s format", (name, icon) => {
getAgentIcon.mockReturnValue(icon);
const style = mentionChipInlineStyle({ kind: "agent", agentId: "agent", icon: name });
const mask = (style as Record<string, string>)["--paperclip-mention-icon-mask"];
expect(decodeURIComponent(mask)).toContain('<path d="m16 18 6-6-6-6"></path>');
it("clears the avatar and any old icon decoration when an anchor stops being a mention", () => {
const anchor = document.createElement("a");
anchor.style.setProperty("--paperclip-mention-icon-mask", "old-icon");
applyMentionChipDecoration(anchor, { kind: "agent", agentId: "agent", icon: "code" });
expect(anchor.style.getPropertyValue("--paperclip-mention-icon-mask")).toBe("");
expect(anchor.style.getPropertyValue("--paperclip-mention-avatar-image")).toContain("/api/agent-avatars/");
clearMentionChipDecoration(anchor);
expect(anchor.hasAttribute("data-mention-kind")).toBe(false);
expect(anchor.style.getPropertyValue("--paperclip-mention-avatar-image")).toBe("");
});
});
+7 -74
View File
@@ -6,8 +6,10 @@ import {
parseRoutineMentionHref,
parseSkillMentionHref,
parseUserMentionHref,
resolveAgentAppearance,
type AgentAppearance,
} from "@paperclipai/shared";
import { getAgentIcon } from "./agent-icons";
import { agentAvatarUrl } from "@/lib/agent-avatar-url";
import { hexToRgb, pickTextColorForPillBg } from "./color-contrast";
export type ParsedMentionChip =
@@ -15,6 +17,7 @@ export type ParsedMentionChip =
kind: "agent";
agentId: string;
icon: string | null;
appearance?: AgentAppearance | null;
}
| {
kind: "issue";
@@ -39,8 +42,6 @@ export type ParsedMentionChip =
routineId: string;
};
const iconMaskCache = new Map<string, string>();
export function parseMentionChipHref(href: string): ParsedMentionChip | null {
if (/^https?:\/\//i.test(href.trim())) {
return null;
@@ -110,10 +111,8 @@ export function mentionChipInlineStyle(mention: ParsedMentionChip): CSSPropertie
}
if (mention.kind === "agent") {
const iconMask = buildAgentIconMask(mention.icon);
if (iconMask) {
style["--paperclip-mention-icon-mask"] = iconMask;
}
const appearance = resolveAgentAppearance(mention.appearance, mention.agentId);
style["--paperclip-mention-avatar-image"] = `url("${agentAvatarUrl(appearance, 16, 2)}")`;
}
return Object.keys(style).length > 0 ? (style as CSSProperties) : undefined;
@@ -159,6 +158,7 @@ export function clearMentionChipDecoration(element: HTMLElement) {
element.style.removeProperty("color");
element.style.removeProperty("--paperclip-mention-project-color");
element.style.removeProperty("--paperclip-mention-icon-mask");
element.style.removeProperty("--paperclip-mention-avatar-image");
}
function projectMentionColors(color: string): Pick<CSSProperties, "borderColor" | "backgroundColor" | "color"> {
@@ -170,70 +170,3 @@ function projectMentionColors(color: string): Pick<CSSProperties, "borderColor"
color: pickTextColorForPillBg(color),
};
}
function buildAgentIconMask(iconName: string | null): string | null {
const cacheKey = iconName ?? "__default__";
const cached = iconMaskCache.get(cacheKey);
if (cached) return cached;
const Icon = getAgentIcon(iconName);
// Lucide's component internals differ between browser and server builds.
// Keep the agent marker visible when its private iconNode is unavailable.
const iconNode = resolveLucideIconNode(Icon) ?? [
["rect", { x: "3", y: "3", width: "18", height: "18", rx: "2" }],
["path", { d: "M8 10h.01M16 10h.01M8 15h8" }],
];
const body = iconNode.map(([tag, attrs]) => {
const attrString = Object.entries(attrs)
.filter(([key]) => key !== "key")
.map(([key, value]) => `${key}="${escapeAttribute(String(value))}"`)
.join(" ");
return `<${tag}${attrString ? ` ${attrString}` : ""}></${tag}>`;
}).join("");
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" ` +
`fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" ` +
`stroke-linejoin="round">${body}</svg>`;
const url = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
iconMaskCache.set(cacheKey, url);
return url;
}
function resolveLucideIconNode(
icon: unknown,
): Array<[string, Record<string, string>]> | null {
const staticIconNode = (
icon as {
iconNode?: Array<[string, Record<string, string>]>;
}
).iconNode;
if (Array.isArray(staticIconNode) && staticIconNode.length > 0) {
return staticIconNode;
}
const render = (
icon as {
render?: (props: Record<string, unknown>, ref: unknown) => {
props?: {
iconNode?: Array<[string, Record<string, string>]>;
icon?: { node?: Array<[string, Record<string, string>]> };
};
} | null;
}
).render;
const rendered = typeof render === "function" ? render({}, null) : null;
const renderedIconNode = rendered?.props?.icon?.node ?? rendered?.props?.iconNode;
return Array.isArray(renderedIconNode) && renderedIconNode.length > 0
? renderedIconNode
: null;
}
function escapeAttribute(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
+6 -4
View File
@@ -26,19 +26,21 @@ export function getRecentAssigneeIds(): string[] {
.filter((id): id is string => Boolean(id));
}
export function getRecentAssigneeSelectionIds(): string[] {
return readRecentSelectionIds(STORAGE_KEY).map((id) => {
export function getRecentAssigneeSelectionIds(companyId?: string): string[] {
return readRecentSelectionIds(companyId ? `${STORAGE_KEY}:${companyId}` : STORAGE_KEY).map((id) => {
if (id.includes(":")) return id;
return agentSelectionId(id);
});
}
export function trackRecentAssignee(agentId: string): void {
export function trackRecentAssignee(agentId: string, companyId?: string): void {
trackRecentSelectionId(STORAGE_KEY, agentSelectionId(agentId));
if (companyId) trackRecentSelectionId(`${STORAGE_KEY}:${companyId}`, agentSelectionId(agentId));
}
export function trackRecentAssigneeUser(userId: string): void {
export function trackRecentAssigneeUser(userId: string, companyId?: string): void {
trackRecentSelectionId(STORAGE_KEY, userSelectionId(userId));
if (companyId) trackRecentSelectionId(`${STORAGE_KEY}:${companyId}`, userSelectionId(userId));
}
export function sortAgentsByRecency<T extends { id: string; name: string }>(
+21
View File
@@ -0,0 +1,21 @@
const STORAGE_KEY = "paperclip:composer-effort";
/** Undefined means no preference; null is the user's explicit Default choice. */
export function getLastComposerEffort(companyId: string): string | null | undefined {
try {
const raw = localStorage.getItem(`${STORAGE_KEY}:${companyId}`);
if (raw === null) return undefined;
const value: unknown = JSON.parse(raw);
return value === null || typeof value === "string" ? value : undefined;
} catch {
return undefined;
}
}
export function rememberComposerEffort(companyId: string, effort: string | null): void {
try {
localStorage.setItem(`${STORAGE_KEY}:${companyId}`, JSON.stringify(effort));
} catch {
// The current run setting still works without persistent storage.
}
}
+17 -1
View File
@@ -9,6 +9,22 @@ export function getRecentProjectIds(): string[] {
return readRecentSelectionIds(STORAGE_KEY);
}
export function trackRecentProject(projectId: string): void {
/** Undefined means no history; an empty string remembers an explicit No project. */
export function getLastProjectId(companyId: string): string | undefined {
try {
return localStorage.getItem(`${STORAGE_KEY}:${companyId}`) ?? undefined;
} catch {
return undefined;
}
}
export function trackRecentProject(projectId: string, companyId?: string): void {
trackRecentSelectionId(STORAGE_KEY, projectId);
if (companyId) {
try {
localStorage.setItem(`${STORAGE_KEY}:${companyId}`, projectId);
} catch {
// Selection remains usable when browser storage is unavailable.
}
}
}
+36 -1
View File
@@ -8,7 +8,8 @@ import {
trackRecentAssignee,
trackRecentAssigneeUser,
} from "./recent-assignees";
import { getRecentProjectIds, trackRecentProject } from "./recent-projects";
import { getLastProjectId, getRecentProjectIds, trackRecentProject } from "./recent-projects";
import { getLastComposerEffort, rememberComposerEffort } from "./recent-composer-effort";
import { orderItemsBySelectedAndRecent } from "./recent-selections";
describe("recent selection ordering", () => {
@@ -69,6 +70,30 @@ describe("recent selection ordering", () => {
expect(getRecentProjectIds()).toEqual(["project-1", "project-2"]);
});
it("remembers projects and explicit No project separately for each company", () => {
expect(getLastProjectId("company-1")).toBeUndefined();
trackRecentProject("project-1", "company-1");
trackRecentProject("project-2", "company-2");
expect(getLastProjectId("company-1")).toBe("project-1");
expect(getLastProjectId("company-2")).toBe("project-2");
trackRecentProject("", "company-1");
expect(getLastProjectId("company-1")).toBe("");
expect(getLastProjectId("company-2")).toBe("project-2");
expect(getRecentProjectIds()).toEqual(["project-2", "project-1"]);
});
it("remembers effort and an explicit Default choice within the company", () => {
expect(getLastComposerEffort("company-1")).toBeUndefined();
rememberComposerEffort("company-1", "high");
rememberComposerEffort("company-2", "ultra");
expect(getLastComposerEffort("company-1")).toBe("high");
rememberComposerEffort("company-1", null);
expect(getLastComposerEffort("company-1")).toBeNull();
expect(getLastComposerEffort("company-2")).toBe("ultra");
localStorage.setItem("paperclip:composer-effort:company-1", "broken json");
expect(getLastComposerEffort("company-1")).toBeUndefined();
});
it("tracks recent user and agent assignee selections with prefixed ids", () => {
trackRecentAssignee("agent-1");
trackRecentAssigneeUser("user-1");
@@ -76,4 +101,14 @@ describe("recent selection ordering", () => {
expect(getRecentAssigneeSelectionIds()).toEqual(["user:user-1", "agent:agent-1"]);
expect(getRecentAssigneeIds()).toEqual(["agent-1"]);
});
it("remembers assignees separately in each company", () => {
trackRecentAssignee("agent-1", "company-1");
trackRecentAssigneeUser("user-1", "company-2");
trackRecentAssigneeUser("user-2", "company-1");
expect(getRecentAssigneeSelectionIds("company-1")).toEqual(["user:user-2", "agent:agent-1"]);
expect(getRecentAssigneeSelectionIds("company-2")).toEqual(["user:user-1"]);
expect(getRecentAssigneeSelectionIds("company-3")).toEqual([]);
});
});
+29
View File
@@ -42,6 +42,7 @@ import {
createIssueDetailLocationState,
} from "../lib/issueDetailBreadcrumb";
import { getRecentTasksStorageKey, readRecentTasks } from "../lib/recent-tasks";
import { getLastProjectId, trackRecentProject } from "../lib/recent-projects";
import { ApiError } from "../api/client";
import type { issuesApi } from "../api/issues";
@@ -1431,6 +1432,34 @@ describe("IssueDetail", () => {
vi.restoreAllMocks();
});
it.each(["new-project", null])("remembers project %s only after its task update succeeds", async (projectId) => {
const issue = createIssue({ projectId: "original-project" });
trackRecentProject("original-project", issue.companyId);
const failedUpdate = createDeferred<Issue>();
const successfulUpdate = createDeferred<Issue>();
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.update.mockClear();
mockIssuesApi.update.mockReturnValueOnce(failedUpdate.promise).mockReturnValueOnce(successfulUpdate.promise);
await act(async () => root.render(<QueryClientProvider client={queryClient}><IssueDetail /></QueryClientProvider>));
let properties!: { onUpdate: (data: Record<string, unknown>) => void };
await waitForAssertion(() => {
properties = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children?.props;
expect(properties?.onUpdate).toBeTypeOf("function");
});
await act(async () => properties.onUpdate({ projectId }));
await waitForAssertion(() => expect(mockIssuesApi.update).toHaveBeenCalledTimes(1));
expect(getLastProjectId(issue.companyId)).toBe("original-project");
await act(async () => failedUpdate.reject(new Error("Project save failed")));
await waitForAssertion(() => expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Task update failed" })));
expect(getLastProjectId(issue.companyId)).toBe("original-project");
await act(async () => properties.onUpdate({ projectId }));
await waitForAssertion(() => expect(mockIssuesApi.update).toHaveBeenCalledTimes(2));
expect(getLastProjectId(issue.companyId)).toBe("original-project");
await act(async () => successfulUpdate.resolve({ ...issue, projectId }));
await waitForAssertion(() => expect(getLastProjectId(issue.companyId)).toBe(projectId ?? ""));
expect(getLastProjectId("company-2")).toBeUndefined();
});
it("keeps an existing conversation on its agent-addressed route", async () => {
const agent = createAgent();
const canonical = createIssue({ conversationAgentId: agent.id, conversationUserId: "user-1", conversationState: "waiting", status: "in_review" });
+5 -1
View File
@@ -7,6 +7,7 @@ import { mergeComposerRunSettings, type ComposerRunSettings } from "@/components
import { AgentIdentity } from "@/components/AgentIdentity";
import { clearLegacyChatMessageRequests } from "@/lib/chat-message-request";
import { agentChatDraft } from "@/lib/agent-chat-draft";
import { trackRecentProject } from "@/lib/recent-projects";
import { Settings as ChatSettings } from "lucide-react";
import { agentDetailHref } from "./agent-detail-navigation";
import { ExecutionBlockerNotice } from "../components/ExecutionBlockerNotice";
@@ -4161,7 +4162,10 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
changes: _changes,
blockedByIssueIds: _blockedByIssueIds,
...nextIssue
}) => {
}, data) => {
if (Object.prototype.hasOwnProperty.call(data, "projectId")) {
trackRecentProject(nextIssue.projectId ?? "", nextIssue.companyId);
}
const issueRefs = new Set<string>([issueId!, nextIssue.id]);
if (nextIssue.identifier) issueRefs.add(nextIssue.identifier);
mergeIssueResponseIntoCaches(issueRefs, nextIssue);
+5 -2
View File
@@ -1,8 +1,11 @@
import { agentAvatarUrl as apiAvatarUrl } from "@paperclipai/shared";
/** Relative to iframe.html, including when a preview is hosted under a branch prefix. */
/** Resolve against iframe.html so CSS backgrounds also work under hosted branch prefixes. */
export const agentAvatarUrl: typeof apiAvatarUrl = (...args) => {
const url = new URL(apiAvatarUrl(...args), "https://storybook.invalid");
const preset = url.pathname.slice("/api/agent-avatars/".length).replace(/\.png$/, "");
return `./agent-avatar-images/${preset}-${url.searchParams.get("size")}-${url.searchParams.get("scale")}.png`;
const path = `./agent-avatar-images/${preset}-${url.searchParams.get("size")}-${url.searchParams.get("scale")}.png`;
// The build-time asset packer needs relative file names. Browser consumers
// need absolute URLs because CSS custom properties use the stylesheet base.
return typeof document === "undefined" ? path : new URL(path, document.baseURI).href;
};
@@ -156,7 +156,7 @@ export function ComposerModelPickerPreview({
<AgentMark agent={agent} size={16} />
<span className="max-w-20 shrink-0 truncate">{agent.name}</span>
<span className="text-muted-foreground" aria-hidden>·</span>
<span className="min-w-0 truncate text-muted-foreground">{modelAvailable ? modelLabel(agent, model) : "Harness default"}</span>
<span className="min-w-0 truncate text-muted-foreground">{modelAvailable ? modelLabel(agent, model) : "Default"}</span>
{effectiveEffort ? <span className="hidden shrink-0 text-muted-foreground sm:inline">{effortLabel}</span> : null}
<ChevronDown className="size-3 shrink-0 text-muted-foreground" aria-hidden />
</button>
@@ -122,6 +122,6 @@ export function fastModeAvailable(agent: ComposerAgent, model: string): boolean
}
export function modelLabel(agent: ComposerAgent, model: string): string {
if (!model) return agent.defaultLabel ?? "Harness default";
if (!model) return agent.defaultLabel ?? "Default";
return agent.models.find((option) => option.id === model)?.label ?? model;
}
@@ -406,7 +406,7 @@ export const ProductionIntermediateWidthPlan: Story = {
play: async ({ canvasElement }) => {
const screen = within(canvasElement.ownerDocument.body);
const chip = screen.getByRole("button", { name: "Remove Plan mode" }).getBoundingClientRect();
const capsule = screen.getByTestId("task-chat-composer-assignee").getBoundingClientRect();
const capsule = screen.getByTestId("task-chat-composer-selection").getBoundingClientRect();
const send = screen.getByRole("button", { name: "Send message" }).getBoundingClientRect();
await expect(chip.height).toBe(capsule.height);
await expect(Math.abs(send.top - capsule.top)).toBeLessThanOrEqual(1);
@@ -522,7 +522,7 @@ function IssueDialogOpener({
useEffect(() => {
if (variant !== "validation") return undefined;
const timer = window.setTimeout(() => {
clickButtonByText("Create Issue");
document.querySelector<HTMLButtonElement>('[aria-label="Create task"]')?.click();
}, 500);
return () => window.clearTimeout(timer);
}, [variant]);
@@ -728,9 +728,8 @@ export const NewIssuePrefilled: Story = {
export const NewIssueMobileAssigneePicker: Story = {
name: "New Issue - Mobile Assignee Picker",
parameters: {
viewport: { defaultViewport: "mobile" },
},
globals: { viewport: { value: "mobile", isRotated: false } },
parameters: { waitForViewport: true },
render: () => (
<DialogStory
eyebrow="NewIssueDialog"
@@ -743,17 +742,8 @@ export const NewIssueMobileAssigneePicker: Story = {
),
play: async () => {
const page = within(document.body);
await userEvent.click(await page.findByRole("button", { name: "Assignee" }));
const search = await page.findByPlaceholderText("Search assignees...");
await userEvent.click(search);
const viewport = constrainVisualViewportForPickerStory();
await new Promise((resolve) => requestAnimationFrame(resolve));
const bounds = search.closest<HTMLElement>("[data-mobile-entity-picker]")?.getBoundingClientRect();
const offsetTop = viewport?.offsetTop ?? 0;
await expect((bounds?.top ?? -1) + offsetTop).toBeGreaterThanOrEqual(offsetTop);
await expect((bounds?.bottom ?? Number.POSITIVE_INFINITY) + offsetTop).toBeLessThanOrEqual(
offsetTop + (viewport?.height ?? window.innerHeight),
);
await userEvent.click(await page.findByRole("button", { name: "Select assignee" }));
await expect(await page.findByRole("listbox", { name: "Assignees" })).toBeVisible();
},
};
@@ -0,0 +1,534 @@
import { useLayoutEffect, useRef, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, waitFor, within } from "storybook/test";
import { useQueryClient } from "@tanstack/react-query";
import {
appearanceForPalette,
buildAgentMentionHref,
buildIssueReferenceHref,
buildProjectMentionHref,
buildRoutineMentionHref,
buildSkillMentionHref,
buildUserMentionHref,
} from "@paperclipai/shared";
import { MarkdownBody } from "@/components/MarkdownBody";
import { NewIssueDialog } from "@/components/NewIssueDialog";
import { useCompany } from "@/context/CompanyContext";
import { useDialog } from "@/context/DialogContext";
import { queryKeys } from "@/lib/queryKeys";
import { trackRecentAssignee, trackRecentAssigneeUser } from "@/lib/recent-assignees";
import { trackRecentProject } from "@/lib/recent-projects";
import { rememberComposerEffort } from "@/lib/recent-composer-effort";
import {
storybookAgents,
storybookAuthSession,
storybookExecutionWorkspaces,
storybookProjects,
storybookIssues,
} from "../fixtures/paperclipData";
const COMPANY_ID = "company-storybook";
const NEW_TASK_AGENTS = [...storybookAgents, { ...storybookAgents[2]!, id: "agent-ceo", name: "CEO", role: "ceo" }].map((agent) =>
agent.id === "agent-codex"
? { ...agent, adapterConfig: { ...agent.adapterConfig, model: "gpt-6-sol" }, appearance: appearanceForPalette("electric-grove") }
: { ...agent, appearance: appearanceForPalette("pink-lemonade") },
);
const REQUEST = "Review the sign-in flow and fix the redirect after a session expires.";
const SKILLS = [{ id: "skill-test-drive", key: "test-it-for-real", slug: "test-it-for-real", name: "Test it for real", description: "Walk through the feature in the browser." }];
const ROUTINES = [{ id: "routine-daily-check", title: "Daily check-in", status: "active" }];
const RICH_REQUEST = [
`Ask [@CodexCoder](${buildAgentMentionHref("agent-codex", "code")}) and [@QAChecker](${buildAgentMentionHref("agent-qa", "shield")}) to review [@Board UI](${buildProjectMentionHref("project-board-ui", storybookProjects[0]!.color)}).`,
`Check [PAP-1602](${buildIssueReferenceHref("PAP-1602")}) with [@Product Lead](${buildUserMentionHref("user-product")}) using [/test-it-for-real](${buildSkillMentionHref(SKILLS[0]!.id, SKILLS[0]!.slug)}).`,
`Use [/routine:Daily check-in](${buildRoutineMentionHref(ROUTINES[0]!.id)}) as the checklist.`,
].join("\n\n");
const WORKTREES = [
{ ...storybookExecutionWorkspaces[0]!, name: "Sign-in redirect", branchName: "codex/sign-in-redirect" },
{ ...storybookExecutionWorkspaces[0]!, id: "worktree-settings", name: "Settings polish", branchName: "codex/settings-polish", projectWorkspaceId: "workspace-release-local", cwd: `${storybookExecutionWorkspaces[0]!.cwd}-settings` },
];
function NewTaskStory({
scenario = "empty",
worktrees = "ready",
isolation = true,
rememberedAssignee = "none",
rememberedProject = false,
rememberedEffort = false,
}: {
scenario?: "empty" | "prefilled" | "title" | "subtask" | "planning" | "error" | "saving" | "rich";
worktrees?: "ready" | "reuse" | "empty" | "loading" | "error";
isolation?: boolean;
rememberedAssignee?: "none" | "agent" | "human";
rememberedProject?: boolean;
rememberedEffort?: boolean;
}) {
const client = useQueryClient();
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
const { openNewIssue } = useDialog();
const opened = useRef(false);
const [submitted, setSubmitted] = useState<Record<string, unknown> | null>(null);
useLayoutEffect(() => {
const originalFetch = window.fetch;
const imageUrls: string[] = [];
window.fetch = async (input, init) => {
const url = new URL(
typeof input === "string" ? input : input instanceof URL ? input.href : input.url,
location.origin,
);
if (url.pathname === `/api/companies/${COMPANY_ID}/skills`) return Response.json(SKILLS);
if (url.pathname === `/api/companies/${COMPANY_ID}/agents`) return Response.json(NEW_TASK_AGENTS);
if (url.pathname === `/api/companies/${COMPANY_ID}/routines`) return Response.json(ROUTINES);
if (url.pathname === `/api/companies/${COMPANY_ID}/execution-workspaces`) {
if (worktrees === "loading") return new Promise<Response>(() => {});
if (worktrees === "error") return Response.json({ error: "Worktrees unavailable" }, { status: 503 });
return Response.json(worktrees === "empty" || url.searchParams.get("projectId") !== "project-board-ui" ? [] : WORKTREES);
}
if (url.pathname === `/api/companies/${COMPANY_ID}/assets/images` && init?.method === "POST") {
const file = init.body instanceof FormData ? init.body.get("file") : null;
if (!(file instanceof File)) return Response.json({ error: "Select an image" }, { status: 400 });
const contentPath = URL.createObjectURL(file);
imageUrls.push(contentPath);
return Response.json({ contentPath });
}
if (url.pathname === `/api/companies/${COMPANY_ID}/issues` && init?.method === "POST") {
if (scenario === "saving") return new Promise<Response>(() => {});
if (scenario === "error")
return Response.json(
{
error: "Could not create the task. Your draft is saved; try again.",
},
{ status: 503 },
);
const data = JSON.parse(String(init.body)) as Record<string, unknown>;
setSubmitted(data);
return Response.json({
...data,
id: "storybook-created-task",
identifier: "PAP-204",
companyId: COMPANY_ID,
});
}
return originalFetch(input, init);
};
return () => {
window.fetch = originalFetch;
imageUrls.forEach((url) => URL.revokeObjectURL(url));
};
}, [scenario, worktrees]);
useLayoutEffect(() => {
if (selectedCompanyId !== COMPANY_ID) {
setSelectedCompanyId(COMPANY_ID);
return;
}
if (opened.current) return;
opened.current = true;
localStorage.removeItem("paperclip:issue-draft");
localStorage.removeItem("paperclip:recent-assignees");
localStorage.removeItem(`paperclip:recent-assignees:${COMPANY_ID}`);
localStorage.removeItem("paperclip:recent-projects");
localStorage.removeItem(`paperclip:recent-projects:${COMPANY_ID}`);
localStorage.removeItem(`paperclip:composer-effort:${COMPANY_ID}`);
if (rememberedAssignee === "agent") trackRecentAssignee("agent-codex", COMPANY_ID);
if (rememberedAssignee === "human") trackRecentAssigneeUser(storybookAuthSession.user.id, COMPANY_ID);
if (rememberedProject) trackRecentProject("project-board-ui", COMPANY_ID);
if (rememberedEffort) rememberComposerEffort(COMPANY_ID, "high");
client.setQueryData(queryKeys.health, { hiddenSettings: [] });
client.setQueryData(queryKeys.auth.session, storybookAuthSession);
client.setQueryData(
queryKeys.agents.list(COMPANY_ID),
NEW_TASK_AGENTS,
);
client.setQueryData(queryKeys.agents.adapterModels(COMPANY_ID, "codex_local"), [
{ id: "gpt-6-sol", label: "GPT-6 Sol" },
{ id: "gpt-6-astra", label: "GPT-6 Astra" },
]);
client.setQueryData(queryKeys.projects.list(COMPANY_ID), isolation ? storybookProjects : storybookProjects.map((project) => ({
...project, executionWorkspacePolicy: { ...project.executionWorkspacePolicy, enabled: false },
})));
client.setQueryData(queryKeys.issues.mentionPool(COMPANY_ID), storybookIssues);
client.setQueryData(queryKeys.companySkills.list(COMPANY_ID), SKILLS);
client.setQueryData(queryKeys.routines.list(COMPANY_ID), ROUTINES);
client.setQueryData(queryKeys.instance.experimentalSettings, {
enableIsolatedWorkspaces: true,
});
const worktreeQueryKey = queryKeys.executionWorkspaces.summaryList(COMPANY_ID, {
projectId: "project-board-ui",
reuseEligible: true,
});
client.removeQueries({ queryKey: worktreeQueryKey, exact: true });
if (worktrees !== "loading" && worktrees !== "error") client.setQueryData(
worktreeQueryKey,
worktrees === "empty" ? [] : WORKTREES,
);
openNewIssue(
scenario === "empty"
? {}
: {
description: scenario === "rich" ? RICH_REQUEST : REQUEST,
...(scenario === "title" ? { title: "Fix the sign-in redirect" } : {}),
assigneeAgentId: "agent-codex",
projectId: "project-board-ui",
projectWorkspaceId: "workspace-board-ui",
workMode: scenario === "planning" ? "planning" : "standard",
...(worktrees === "reuse" ? { executionWorkspaceId: WORKTREES[0]!.id } : {}),
...(scenario === "subtask"
? {
parentId: "issue-storybook-1",
parentIdentifier: "PAP-203",
parentTitle: "Improve sign-in reliability",
executionWorkspaceId: WORKTREES[0]!.id,
parentExecutionWorkspaceLabel: WORKTREES[0]!.name,
}
: {}),
},
);
}, [client, isolation, openNewIssue, rememberedAssignee, rememberedProject, rememberedEffort, scenario, selectedCompanyId, setSelectedCompanyId, worktrees]);
return (
<div className="min-h-screen bg-background p-8 text-foreground">
{submitted ? (
<div role="status" className="mt-4 space-y-2">
<p>Task created</p>
<MarkdownBody>{String(submitted.description ?? submitted.title)}</MarkdownBody>
<p>Mode: {String(submitted.workMode)}</p>
<p>Worktrees: {String(submitted.executionWorkspacePreference ?? "Project default")}</p>
<p>Checkout: {String(submitted.projectWorkspaceId ?? "None")}</p>
{submitted.executionWorkspaceId ? <p>Reused: {String(submitted.executionWorkspaceId)}</p> : null}
</div>
) : null}
<NewIssueDialog />
</div>
);
}
const meta = {
title: "Composer/New task",
component: NewTaskStory,
parameters: {
layout: "fullscreen",
controls: { disable: true },
options: { showPanel: false },
waitForViewport: true,
docs: {
description: {
component:
"The production new-task dialog renders TaskChatComposer. An inset bar shared with queued messages holds the colored project picker and, for projects with isolation enabled, Worktrees. Choose a new worktree or reuse a project worktree. The shared editor supports agent, person, project, and task mentions plus skill and routine slash-command chips. Agent mentions show the agent's current avatar. The editor, add menu, work modes, assignee/model/effort picker, and send button are shared with task chat. Creation is mocked locally; no agents run.",
},
},
},
args: { scenario: "empty" },
} satisfies Meta<typeof NewTaskStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = {
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await expect(await page.findByRole("button", { name: "Create task" })).toBeDisabled();
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
await waitFor(() => expect(page.getByTestId("task-chat-composer-assignee-label")).toHaveTextContent("CEO"));
await expect(page.getByRole("textbox", { name: "editable markdown" })).toHaveTextContent("");
await expect(page.getByRole("textbox", { name: "editable markdown" }).querySelector('[data-mention-kind]')).toBeNull();
},
};
export const LastAssignee: Story = {
args: { rememberedAssignee: "agent" },
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await waitFor(() => expect(page.getByTestId("task-chat-composer-assignee-label")).toHaveTextContent("CodexCoder"));
await expect(page.getByRole("textbox", { name: "editable markdown" })).toHaveTextContent("");
},
};
export const ReturningPreferences: Story = {
args: { rememberedAssignee: "agent", rememberedProject: true, rememberedEffort: true },
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await waitFor(() => expect(page.getByTestId("task-chat-composer-assignee-label")).toHaveTextContent("CodexCoder"));
await expect(page.getByTestId("task-chat-composer-context")).toHaveTextContent("Board UI");
await userEvent.click(page.getByRole("button", { name: "Select model and effort" }));
await waitFor(() => expect(page.getByTestId("selected-effort")).toHaveTextContent("High"));
},
};
export const MobileReturningPreferences: Story = {
...ReturningPreferences,
globals: { viewport: { value: "mobile", isRotated: false } },
};
export const HumanAssignee: Story = {
args: { rememberedAssignee: "human" },
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await waitFor(() => expect(page.getByTestId("task-chat-composer-assignee-label")).toHaveTextContent("Me"));
await expect(page.queryByRole("button", { name: "Select model and effort" })).not.toBeInTheDocument();
await expect(page.queryByTestId("task-chat-composer-model-label")).not.toBeInTheDocument();
},
};
export const AssigneePicker: Story = {
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await userEvent.click(await page.findByRole("button", { name: "Select assignee" }));
await expect(page.getByRole("searchbox", { name: "Search assignees" })).toBeVisible();
await expect(page.getByRole("listbox", { name: "Assignees" })).toBeVisible();
},
};
export const NoAssignee: Story = {
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await userEvent.click(await page.findByRole("button", { name: "Select assignee" }));
await userEvent.click(page.getByRole("option", { name: "No assignee" }));
await expect(page.queryByRole("listbox", { name: "Assignees" })).not.toBeInTheDocument();
await expect(page.getByTestId("task-chat-composer-assignee-label")).toHaveTextContent("No assignee");
await expect(page.queryByTestId("task-chat-composer-model-label")).not.toBeInTheDocument();
},
};
export const MobileAssigneePicker: Story = {
...AssigneePicker,
globals: { viewport: { value: "mobile", isRotated: false } },
};
export const Prefilled: Story = { args: { scenario: "prefilled" } };
export const InheritedTitle: Story = { args: { scenario: "title" } };
export const Planning: Story = { args: { scenario: "planning" } };
export const SubTask: Story = { args: { scenario: "subtask" } };
export const ProjectPicker: Story = {
args: { scenario: "prefilled" },
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await userEvent.click(await page.findByRole("button", { name: "Board UI" }));
await expect(page.getByPlaceholderText("Search projects...")).toBeVisible();
},
};
export const ModelPicker: Story = {
args: { scenario: "prefilled" },
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await userEvent.click(
await page.findByRole("button", {
name: "Select model and effort",
}),
);
await expect(page.getByRole("button", { name: "Choose exact model" })).toBeVisible();
},
};
export const Files: Story = {
args: { scenario: "prefilled" },
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await page.findByTestId("task-chat-composer-input");
const input = canvasElement.ownerDocument.querySelector<HTMLInputElement>('input[type="file"]')!;
await userEvent.upload(input, [
new File(["# Sign-in plan\n\nReproduce the redirect."], "plan.md", {
type: "text/markdown",
}),
new File(["fixture"], "redirect.png", { type: "image/png" }),
]);
await expect(page.getByText("plan.md")).toBeVisible();
await expect(page.getByText("redirect.png")).toBeVisible();
},
};
export const Saving: Story = {
args: { scenario: "saving" },
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
const send = await page.findByRole("button", { name: "Create task" });
await userEvent.click(send);
await expect(send).toHaveAttribute("aria-busy", "true");
await expect(page.getByTestId("task-chat-composer-input")).toHaveTextContent(REQUEST);
},
};
export const SaveError: Story = {
args: { scenario: "error" },
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await userEvent.click(await page.findByRole("button", { name: "Create task" }));
await expect(await page.findByRole("alert")).toHaveTextContent("Your draft is saved");
await expect(page.getByTestId("task-chat-composer-input")).toHaveTextContent(REQUEST);
},
};
export const CreateFromComposer: Story = {
args: { scenario: "planning" },
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await userEvent.click(await page.findByRole("button", { name: "Create task" }));
await expect(await page.findByRole("status")).toHaveTextContent("Task created");
await expect(page.getByRole("status")).toHaveTextContent("Mode: planning");
},
};
export const Light: Story = {
args: { scenario: "prefilled" },
globals: { theme: "light" },
};
export const Mobile: Story = {
args: { scenario: "prefilled" },
globals: { viewport: { value: "mobile", isRotated: false } },
};
export const MobileProjectPicker: Story = {
...ProjectPicker,
globals: { viewport: { value: "mobile", isRotated: false } },
};
export const MobileModelPicker: Story = {
...ModelPicker,
globals: { viewport: { value: "mobile", isRotated: false } },
};
export const WorktreePicker: Story = {
args: { scenario: "prefilled" },
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await userEvent.click(await page.findByRole("combobox", { name: "Worktrees" }));
await expect(page.getByPlaceholderText("Search worktrees...")).toBeVisible();
await expect(page.getByRole("option", { name: /New worktree/ })).toBeVisible();
await expect(page.getByRole("option", { name: /Sign-in redirect/ })).toBeVisible();
},
};
export const ReuseWorktree: Story = {
args: { scenario: "prefilled", worktrees: "reuse" },
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await expect(await page.findByRole("combobox", { name: "Worktrees" })).toHaveTextContent("Sign-in redirect");
},
};
export const CreateWithReusedWorktree: Story = {
args: { scenario: "prefilled" },
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await userEvent.click(await page.findByRole("combobox", { name: "Worktrees" }));
await userEvent.type(page.getByPlaceholderText("Search worktrees..."), "settings");
await userEvent.click(await page.findByRole("option", { name: /Settings polish/ }));
await expect(page.getByRole("combobox", { name: "Worktrees" })).toHaveTextContent("Settings polish");
await userEvent.click(page.getByRole("button", { name: "Create task" }));
await expect(await page.findByRole("status")).toHaveTextContent("Worktrees: reuse_existing");
await expect(page.getByRole("status")).toHaveTextContent("Reused: worktree-settings");
await expect(page.getByRole("status")).toHaveTextContent("Checkout: workspace-release-local");
},
};
export const NewWorktreeAfterReuse: Story = {
args: { scenario: "prefilled" },
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await userEvent.click(await page.findByRole("combobox", { name: "Worktrees" }));
await userEvent.click(await page.findByRole("option", { name: /Settings polish/ }));
await userEvent.click(page.getByRole("combobox", { name: "Worktrees" }));
await userEvent.click(page.getByRole("option", { name: /New worktree/ }));
await userEvent.click(page.getByRole("button", { name: "Create task" }));
await expect(await page.findByRole("status")).toHaveTextContent("Worktrees: isolated_workspace");
await expect(page.getByRole("status")).toHaveTextContent("Checkout: workspace-board-ui");
await expect(page.getByRole("status")).not.toHaveTextContent("Reused:");
},
};
export const EmptyWorktrees: Story = { ...WorktreePicker, args: { scenario: "prefilled", worktrees: "empty" }, play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await userEvent.click(await page.findByRole("combobox", { name: "Worktrees" }));
await expect(page.getByText(/No existing worktrees yet/)).toBeVisible();
await expect(page.getByRole("option", { name: /New worktree/ })).toBeVisible();
} };
export const LoadingWorktrees: Story = { ...EmptyWorktrees, args: { scenario: "prefilled", worktrees: "loading" }, play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await userEvent.click(await page.findByRole("combobox", { name: "Worktrees" }));
await expect(page.getByText("Loading existing worktrees…")).toBeVisible();
await expect(page.getByRole("option", { name: /New worktree/ })).toBeVisible();
} };
export const WorktreeError: Story = { ...EmptyWorktrees, args: { scenario: "prefilled", worktrees: "error" }, play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await userEvent.click(await page.findByRole("combobox", { name: "Worktrees" }));
await expect(await page.findByRole("button", { name: "Retry" })).toBeVisible();
await expect(page.getByRole("option", { name: /New worktree/ })).toBeVisible();
} };
export const IsolationDisabled: Story = { args: { scenario: "prefilled", isolation: false }, play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await expect(await page.findByRole("button", { name: "Board UI" })).toBeVisible();
await expect(page.queryByRole("combobox", { name: "Worktrees" })).not.toBeInTheDocument();
} };
export const MobileWorktreePicker: Story = {
...WorktreePicker,
globals: { viewport: { value: "mobile", isRotated: false } },
};
export const MentionPicker: Story = {
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
const editor = await page.findByRole("textbox", { name: "editable markdown" });
await userEvent.type(editor, "@");
const menu = await page.findByTestId("mention-autocomplete-menu");
await expect(within(menu).getByText("CodexCoder")).toBeVisible();
await expect(menu.querySelector('[data-slot="agent-avatar"] img')).toBeVisible();
await expect(within(menu).getByText("Product Lead")).toBeVisible();
await expect(within(menu).getByText("Board UI")).toBeVisible();
await expect(within(menu).getByText("PAP-1602")).toBeVisible();
await expect(menu.getBoundingClientRect().right).toBeLessThanOrEqual(canvasElement.ownerDocument.defaultView!.innerWidth);
},
};
export const SlashCommands: Story = {
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await userEvent.type(await page.findByRole("textbox", { name: "editable markdown" }), "/");
const menuElement = await page.findByTestId("mention-autocomplete-menu");
const menu = within(menuElement);
await expect(menu.getByText("/test-it-for-real")).toBeVisible();
await expect(menu.getByText("/routine:Daily check-in")).toBeVisible();
await expect(menuElement.getBoundingClientRect().right).toBeLessThanOrEqual(canvasElement.ownerDocument.defaultView!.innerWidth);
},
};
export const RichChips: Story = {
args: { scenario: "rich" },
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
const composer = await page.findByTestId("task-chat-composer-input");
for (const kind of ["agent", "user", "project", "issue", "skill", "routine"]) {
await waitFor(() => expect(composer.querySelector(`[data-mention-kind="${kind}"]`)).toBeVisible());
}
const agentChip = composer.querySelector('[data-mention-kind="agent"]')!;
await expect(agentChip).toHaveAttribute("style", expect.stringContaining("--paperclip-mention-avatar-image"));
await expect(canvasElement.ownerDocument.defaultView!.getComputedStyle(agentChip, "::before").backgroundImage)
.toContain(new URL("./agent-avatar-images/", canvasElement.ownerDocument.baseURI).href);
},
};
export const CreateWithRichChips: Story = {
args: { scenario: "rich" },
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await userEvent.click(await page.findByRole("button", { name: "Create task" }));
const receipt = await page.findByRole("status");
await expect(receipt).toHaveTextContent("Task created");
await expect(receipt).toHaveTextContent("/test-it-for-real");
await expect(receipt).toHaveTextContent("/routine:Daily check-in");
await expect(receipt.querySelector('[data-mention-kind="agent"]')).toHaveAttribute("style", expect.stringContaining("electric-grove"));
},
};
export const InsertRichChips: Story = {
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
const editor = await page.findByRole("textbox", { name: "editable markdown" });
await userEvent.click(editor);
for (const [query, option] of [
["@Codex", /CodexCoder/],
[" @Product", /Product Lead/],
[" @Board UI", /Board UI/],
[" @PAP-1602", /PAP-1602/],
[" /test-it", /test-it-for-real/],
[" /routine:Daily", /routine:Daily check-in/],
] as const) {
await waitFor(() => expect(editor).toHaveFocus());
await userEvent.keyboard(query);
const menu = within(await page.findByTestId("mention-autocomplete-menu"));
await userEvent.click(await menu.findByRole("button", { name: option }));
}
await waitFor(() => expect(editor).toHaveFocus());
await userEvent.keyboard(" Please review these together.");
await userEvent.click(page.getByRole("button", { name: "Create task" }));
const receipt = await page.findByRole("status");
await expect(receipt).toHaveTextContent("Please review these together.");
for (const kind of ["agent", "user", "project", "skill", "routine"]) {
await expect(receipt.querySelector(`[data-mention-kind="${kind}"]`)).toBeVisible();
}
await expect(within(receipt).getByRole("link", { name: "Issue PAP-1602" })).toBeVisible();
},
};
export const MobileMentionPicker: Story = { ...MentionPicker, globals: { viewport: { value: "mobile", isRotated: false } } };
export const MobileSlashCommands: Story = { ...SlashCommands, globals: { viewport: { value: "mobile", isRotated: false } } };
export const MobileRichChips: Story = { ...RichChips, globals: { viewport: { value: "mobile", isRotated: false } } };
export const MobileInsertRichChips: Story = { ...InsertRichChips, globals: { viewport: { value: "mobile", isRotated: false } } };