feat(ui): compact task prompt chips (#3665)

This commit is contained in:
Carlos Florêncio
2026-09-14 18:45:35 +01:00
committed by GitHub
parent afeebea2da
commit da204b8cdb
13 changed files with 1305 additions and 85 deletions
@@ -16,8 +16,19 @@ const PROMPT = {
updated_at: "2026-09-12T00:00:00Z",
};
const PROMPT_REFERENCE_REMOVE_TEST_ID = "task-prompt-reference-remove";
const PROMPT_REFERENCE_TEST_ID = "task-prompt-reference";
const PROMPT_MENTION_TEST_ID = "custom-prompt-mention";
const PROMPT_REFERENCE_SELECTOR = '[data-testid="task-prompt-reference"]';
const touchState = vi.hoisted(() => ({ enabled: false }));
afterEach(cleanup);
vi.mock("@/hooks/use-compact-task-chrome", () => ({
useTouchDrawer: () => touchState.enabled,
}));
afterEach(() => {
cleanup();
touchState.enabled = false;
});
function PromptStoreCapture({ storeRef }: { storeRef: { current: StoreApi<AppState> | null } }) {
const store = useAppStoreApi();
@@ -60,7 +71,7 @@ describe("TaskPromptReferenceEditor basic behavior", () => {
).toBe("true");
expect(screen.getByRole("textbox", { name: "Describe the task" })).toBeTruthy();
await waitFor(() =>
expect(screen.getByTestId("custom-prompt-mention").textContent).toBe(`@${PROMPT.name}`),
expect(screen.getByTestId(PROMPT_MENTION_TEST_ID).textContent).toBe(`@${PROMPT.name}`),
);
expect(screen.getByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID)).toBeTruthy();
expect(ref.current?.getValue()).toBe(`Before @${PROMPT.name}`);
@@ -88,8 +99,73 @@ describe("TaskPromptReferenceEditor basic behavior", () => {
await waitFor(() => {
expect(ref.current?.getValue()).toBe(` and @${PROMPT.name}`);
expect(onChange).toHaveBeenCalledWith(` and @${PROMPT.name}`);
expect(screen.queryByText(PROMPT.content)).toBeNull();
});
});
it("renders editable references as a compact shell with contained controls", async () => {
renderEditor(`Before @${PROMPT.name}`);
await waitFor(() => expect(screen.getByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID)).toBeTruthy());
const shell = screen.getByTestId(PROMPT_REFERENCE_TEST_ID);
const mention = screen.getByTestId(PROMPT_MENTION_TEST_ID);
const label = screen.getByTestId("custom-prompt-mention-label");
const remove = screen.getByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID);
const borderedElements = [
shell,
...Array.from(shell.querySelectorAll<HTMLElement>("[class]")),
].filter((element) => element.className.includes("border-emerald-300/35"));
expect(shell.className).toContain("h-6");
expect(shell.className).toContain("min-w-0");
expect(shell.className).toContain("box-border");
expect(borderedElements).toHaveLength(1);
expect(mention.className).toContain("text-xs");
expect(mention.className).toContain("min-w-0");
expect(label.className).toContain("min-w-0");
expect(label.className).toContain("truncate");
expect(remove.className).toContain("h-5");
expect(remove.className).toContain("focus-visible");
expect(mention.closest(PROMPT_REFERENCE_SELECTOR)).toBe(shell);
expect(remove.closest(PROMPT_REFERENCE_SELECTOR)).toBe(shell);
});
it("keeps a long reference label accessible while reserving removal space", async () => {
const longName = "a-very-long-prompt-name-that-must-stay-inside-the-editor";
const longPrompt = { ...PROMPT, name: longName };
renderEditor(`@${longName}`, createRef<RichTextInputHandle>(), [longPrompt]);
await waitFor(() => expect(screen.getByTestId(PROMPT_MENTION_TEST_ID)).toBeTruthy());
const mention = screen.getByTestId(PROMPT_MENTION_TEST_ID);
const label = screen.getByTestId("custom-prompt-mention-label");
expect(mention.getAttribute("aria-label")).toBe(`Custom prompt: ${longName}`);
expect(label.className).toContain("truncate");
expect(label.className).toContain("min-w-0");
expect(screen.getByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID)).toBeTruthy();
});
it("keeps preview and removal as separate touch-sized targets inside the shell", async () => {
touchState.enabled = true;
renderEditor(`@${PROMPT.name}`);
await waitFor(() => expect(screen.getByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID)).toBeTruthy());
const shell = screen.getByTestId(PROMPT_REFERENCE_TEST_ID);
const mention = screen.getByTestId(PROMPT_MENTION_TEST_ID);
const remove = screen.getByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID);
expect(shell.className).toContain("min-h-11");
expect(shell.className).toContain("box-border");
expect(shell.className).not.toContain("box-content");
expect(mention.className).toContain("h-11");
expect(mention.className).toContain("min-w-11");
expect(remove.className).toContain("h-11");
expect(remove.className).toContain("min-w-11");
expect(mention.closest(PROMPT_REFERENCE_SELECTOR)).toBe(shell);
expect(remove.closest(PROMPT_REFERENCE_SELECTOR)).toBe(shell);
});
});
describe("TaskPromptReferenceEditor reconciliation", () => {
@@ -6,6 +6,7 @@ import { IconX } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { PromptMentionChip } from "@/components/task/chat/messages/prompt-mention-components";
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { cn } from "@/lib/utils";
export type TaskPromptReferenceAttrs = {
@@ -51,6 +52,8 @@ export const TaskPromptReference = Node.create({
function TaskPromptReferenceView({ node, deleteNode }: ReactNodeViewProps) {
const { t } = useTranslation();
const usesTouchDrawer = useTouchDrawer();
const { isMobile } = useResponsiveBreakpoint();
const usesTouchTarget = isMobile || usesTouchDrawer;
const attrs = node.attrs as TaskPromptReferenceAttrs;
const removeLabel = t("task:removeLabeled", { label: attrs.value });
@@ -59,9 +62,12 @@ function TaskPromptReferenceView({ node, deleteNode }: ReactNodeViewProps) {
as="span"
data-testid="task-prompt-reference"
data-prompt-name={attrs.name}
className="inline-flex max-w-full items-center align-baseline"
className={cn(
"inline-flex max-w-full min-w-0 box-border items-center gap-0.5 rounded-md border border-emerald-300/35 bg-emerald-400/20 px-1 align-baseline",
usesTouchTarget ? "min-h-11" : "h-6",
)}
>
<PromptMentionChip name={attrs.name} value={attrs.value} />
<PromptMentionChip name={attrs.name} value={attrs.value} presentation="editable" />
<button
type="button"
data-testid="task-prompt-reference-remove"
@@ -69,8 +75,8 @@ function TaskPromptReferenceView({ node, deleteNode }: ReactNodeViewProps) {
title={removeLabel}
contentEditable={false}
className={cn(
"inline-flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground hover:bg-muted/50 hover:text-foreground",
usesTouchDrawer && "h-11 min-w-11",
"inline-flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-emerald-500/15 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-emerald-500/80",
usesTouchTarget && "h-11 min-h-11 min-w-11 w-11",
)}
onMouseDown={(event) => {
event.preventDefault();
@@ -1,5 +1,35 @@
import { describe, expect, it } from "vitest";
import { splitMarkdownPromptMentionSegments } from "./prompt-mention-components";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { StateProvider } from "@/components/state-provider";
import { PromptMentionChip, splitMarkdownPromptMentionSegments } from "./prompt-mention-components";
const PROMPT = {
id: "prompt-1",
name: "daily",
content: "Review the daily report",
builtin: false,
created_at: "2026-09-12T00:00:00Z",
updated_at: "2026-09-12T00:00:00Z",
};
const INITIAL_PROMPT_STATE = {
prompts: { items: [PROMPT], loaded: true, loading: false },
};
const EMPTY_PROMPT_STATE = {
prompts: { items: [], loaded: true, loading: false },
};
const PROMPT_MENTION_TEST_ID = "custom-prompt-mention";
const PROMPT_MENTION_LABEL_TEST_ID = "custom-prompt-mention-label";
const touchState = vi.hoisted(() => ({ enabled: false }));
vi.mock("@/hooks/use-compact-task-chrome", () => ({
useTouchDrawer: () => touchState.enabled,
}));
afterEach(() => {
cleanup();
touchState.enabled = false;
});
const promptNames = ["daily"];
@@ -92,3 +122,64 @@ describe("splitMarkdownPromptMentionSegments round-two boundaries", () => {
]);
});
});
describe("PromptMentionChip presentation", () => {
it("keeps the default read-only chip presentation unchanged", () => {
render(
<StateProvider initialState={INITIAL_PROMPT_STATE}>
<PromptMentionChip name={PROMPT.name} value="@daily" />
</StateProvider>,
);
const chip = screen.getByTestId(PROMPT_MENTION_TEST_ID);
expect(chip.className).toContain("border-emerald-300/35");
expect(chip.className).toContain("bg-emerald-400/20");
expect(chip.className).toContain("px-1.5");
expect(chip.className).not.toContain("h-11");
expect(chip.className).not.toContain("min-w-11");
});
it("keeps the default touch preview target at its established size", () => {
touchState.enabled = true;
render(
<StateProvider initialState={INITIAL_PROMPT_STATE}>
<PromptMentionChip name={PROMPT.name} value="@daily" />
</StateProvider>,
);
const chip = screen.getByTestId(PROMPT_MENTION_TEST_ID);
expect(chip.tagName).toBe("BUTTON");
expect(chip.className).toContain("h-11");
expect(chip.className).toContain("min-w-11");
});
it("uses the compact editable presentation only when opted in", () => {
render(
<StateProvider initialState={INITIAL_PROMPT_STATE}>
<PromptMentionChip name={PROMPT.name} value="@daily" presentation="editable" />
</StateProvider>,
);
const chip = screen.getByTestId(PROMPT_MENTION_TEST_ID);
expect(chip.className).not.toContain("border-emerald-300/35");
expect(chip.className).not.toContain("bg-emerald-400/20");
const label = screen.getByTestId(PROMPT_MENTION_LABEL_TEST_ID);
expect(label.className).toContain("min-w-0");
expect(label.className).toContain("truncate");
});
it("keeps the editable fallback label in a truncation box", () => {
const name = "missing-prompt-with-a-long-name";
render(
<StateProvider initialState={EMPTY_PROMPT_STATE}>
<PromptMentionChip name={name} value={`@${name}`} presentation="editable" />
</StateProvider>,
);
const chip = screen.getByTestId(PROMPT_MENTION_TEST_ID);
const label = screen.getByTestId(PROMPT_MENTION_LABEL_TEST_ID);
expect(chip.getAttribute("title")).toBe(`Custom prompt: ${name}`);
expect(label.className).toContain("min-w-0");
expect(label.className).toContain("truncate");
});
});
@@ -37,6 +37,7 @@ import {
import { matchPromptMention, type PromptMentionMatch } from "@/lib/prompts/prompt-mention-parser";
import type { EntityReference } from "@/lib/types/entity-reference";
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { buildEntityReferenceMarkdownComponents } from "./entity-reference-chip";
type PromptMentionMarkdownTag =
@@ -73,6 +74,12 @@ type MarkdownChildrenProps<T extends PromptMentionMarkdownTag> = ComponentPropsW
export const PROMPT_MENTION_CHIP_CLASS =
"inline rounded-md border border-emerald-300/35 bg-emerald-400/20 px-1.5 py-0.5 font-mono text-[0.88em] font-semibold text-emerald-950 box-decoration-clone break-all dark:text-emerald-100";
const PROMPT_MENTION_EDITABLE_TEXT_CLASS =
"inline-flex max-w-full min-w-0 items-center font-mono text-xs font-semibold text-emerald-950 dark:text-emerald-100";
const PROMPT_MENTION_EDITABLE_LABEL_CLASS = "min-w-0 max-w-full flex-1 truncate";
const PROMPT_MENTION_EDITABLE_FOCUS_CLASS =
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-emerald-500/80";
export function useStablePromptMentionNames(promptNames: string[]) {
const canonicalNames = buildPromptMentionNames(promptNames);
const stableNamesRef = useRef<string[]>([]);
@@ -649,17 +656,194 @@ export function PromptMentionText({
);
}
type PromptMentionPresentation = "default" | "editable";
type PromptMentionChipProps = {
name: string;
value: string;
focusable?: boolean;
interactive?: boolean;
presentation?: PromptMentionPresentation;
};
type PromptMentionPreviewBaseProps = {
name: string;
value: string;
label: string;
content: string;
className: string;
labelClassName?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
};
type PromptMentionTouchPreviewProps = PromptMentionPreviewBaseProps & {
description: string;
};
type PromptMentionHoverPreviewProps = PromptMentionPreviewBaseProps & {
focusable: boolean;
onToggle: () => void;
};
type PromptMentionClassNames = {
text: string;
trigger: string;
labelClassName?: string;
};
function getPromptMentionClassNames(
presentation: PromptMentionPresentation,
usesTouchDrawer: boolean,
usesTouchTarget: boolean,
): PromptMentionClassNames {
if (presentation === "default") {
return {
text: PROMPT_MENTION_CHIP_CLASS,
trigger: cn(PROMPT_MENTION_CHIP_CLASS, usesTouchDrawer && "h-11 min-w-11", "cursor-pointer"),
};
}
return {
text: PROMPT_MENTION_EDITABLE_TEXT_CLASS,
labelClassName: PROMPT_MENTION_EDITABLE_LABEL_CLASS,
trigger: cn(
PROMPT_MENTION_EDITABLE_TEXT_CLASS,
PROMPT_MENTION_EDITABLE_FOCUS_CLASS,
usesTouchTarget ? "h-11 min-w-11" : "min-w-0",
"cursor-pointer",
),
};
}
function PromptMentionLabel({ value, className }: { value: string; className?: string }) {
if (!className) return value;
return (
<span data-testid="custom-prompt-mention-label" className={className}>
{value}
</span>
);
}
function PromptMentionFallback({
name,
value,
className,
labelClassName,
}: Pick<PromptMentionPreviewBaseProps, "name" | "value" | "className" | "labelClassName">) {
const { t } = useTranslation();
return (
<span
data-testid="custom-prompt-mention"
data-prompt-name={name}
title={t("task:customPromptNamed", { name })}
className={className}
>
<PromptMentionLabel value={value} className={labelClassName} />
</span>
);
}
function PromptMentionTouchPreview({
name,
value,
label,
content,
className,
labelClassName,
description,
open,
onOpenChange,
}: PromptMentionTouchPreviewProps) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerTrigger asChild>
<button
type="button"
data-testid="custom-prompt-mention"
data-prompt-name={name}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={label}
title={labelClassName ? label : undefined}
className={className}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => event.stopPropagation()}
>
<PromptMentionLabel value={value} className={labelClassName} />
</button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{label}</DrawerTitle>
<DrawerDescription className="sr-only">{description}</DrawerDescription>
</DrawerHeader>
<div className="max-h-[70dvh] overflow-y-auto px-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
<PromptPreview content={content} />
</div>
</DrawerContent>
</Drawer>
);
}
function PromptMentionHoverPreview({
name,
value,
label,
content,
focusable,
className,
labelClassName,
open,
onOpenChange,
onToggle,
}: PromptMentionHoverPreviewProps) {
const handleClick = (event: MouseEvent) => {
event.stopPropagation();
onToggle();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!focusable || (event.key !== "Enter" && event.key !== " ")) return;
event.preventDefault();
event.stopPropagation();
onToggle();
};
return (
<HoverCard open={open} onOpenChange={onOpenChange} openDelay={300} closeDelay={0}>
<HoverCardTrigger asChild>
<span
data-testid="custom-prompt-mention"
data-prompt-name={name}
tabIndex={focusable ? 0 : undefined}
role={focusable ? "button" : undefined}
aria-expanded={focusable ? open : undefined}
aria-label={focusable ? label : undefined}
title={labelClassName ? label : undefined}
className={className}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
<PromptMentionLabel value={value} className={labelClassName} />
</span>
</HoverCardTrigger>
<HoverCardContent side="top" align="start" className="w-80 max-h-80 overflow-y-auto">
<PromptPreview content={content} />
</HoverCardContent>
</HoverCard>
);
}
export function PromptMentionChip({
name,
value,
focusable = true,
interactive = true,
}: {
name: string;
value: string;
focusable?: boolean;
interactive?: boolean;
}) {
presentation = "default",
}: PromptMentionChipProps) {
const { t } = useTranslation();
const content = useAppStore(
useCallback(
@@ -669,83 +853,42 @@ export function PromptMentionChip({
);
const [open, setOpen] = useState(false);
const usesTouchDrawer = useTouchDrawer();
const { isMobile } = useResponsiveBreakpoint();
const usesTouchTarget = isMobile || usesTouchDrawer;
const classNames = getPromptMentionClassNames(presentation, usesTouchDrawer, usesTouchTarget);
if (!content || !interactive) {
return (
<span
data-testid="custom-prompt-mention"
data-prompt-name={name}
title={t("task:customPromptNamed", { name })}
className={PROMPT_MENTION_CHIP_CLASS}
>
{value}
</span>
<PromptMentionFallback
name={name}
value={value}
className={classNames.text}
labelClassName={classNames.labelClassName}
/>
);
}
const label = t("task:customPromptNamed", { name });
const handleToggle = () => setOpen((isOpen) => !isOpen);
const handleClick = (event: MouseEvent) => {
event.stopPropagation();
handleToggle();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!focusable || (event.key !== "Enter" && event.key !== " ")) return;
event.preventDefault();
event.stopPropagation();
handleToggle();
const previewProps: PromptMentionPreviewBaseProps = {
name,
value,
label,
content,
className: classNames.trigger,
labelClassName: classNames.labelClassName,
open,
onOpenChange: setOpen,
};
if (usesTouchDrawer) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<button
type="button"
data-testid="custom-prompt-mention"
data-prompt-name={name}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={label}
className={cn(PROMPT_MENTION_CHIP_CLASS, "h-11 min-w-11 cursor-pointer")}
onClick={(event) => event.stopPropagation()}
>
{value}
</button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{label}</DrawerTitle>
<DrawerDescription className="sr-only">{label}</DrawerDescription>
</DrawerHeader>
<div className="max-h-[70dvh] overflow-y-auto px-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
<PromptPreview content={content} />
</div>
</DrawerContent>
</Drawer>
);
return <PromptMentionTouchPreview {...previewProps} description={t("task:prompt")} />;
}
return (
<HoverCard open={open} onOpenChange={setOpen} openDelay={300} closeDelay={0}>
<HoverCardTrigger asChild>
<span
data-testid="custom-prompt-mention"
data-prompt-name={name}
tabIndex={focusable ? 0 : undefined}
role={focusable ? "button" : undefined}
aria-expanded={focusable ? open : undefined}
aria-label={focusable ? label : undefined}
className={cn(PROMPT_MENTION_CHIP_CLASS, "cursor-pointer")}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
{value}
</span>
</HoverCardTrigger>
<HoverCardContent side="top" align="start" className="w-80 max-h-80 overflow-y-auto">
<PromptPreview content={content} />
</HoverCardContent>
</HoverCard>
<PromptMentionHoverPreview
{...previewProps}
focusable={focusable}
onToggle={() => setOpen((isOpen) => !isOpen)}
/>
);
}
@@ -1,5 +1,5 @@
import { expect, test } from "../../fixtures/test-base";
import type { Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { waitForHttp } from "../../helpers/causal-waits";
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
import { expectTaskDescription } from "../../pages/task-description-editor";
@@ -8,11 +8,48 @@ import { useRegularMode } from "../../helpers/regular-mode";
useRegularMode();
const PROMPT_NAME = "mobile-task-reference-prompt";
const LONG_PROMPT_NAME =
"mobile-task-reference-with-a-name-that-definitely-overflows-the-editor-width";
const PROMPT_CONTENT = Array.from(
{ length: 28 },
(_, index) => `Mobile preview instruction ${index + 1}: preserve this saved guidance.`,
).join("\n");
type Box = { x: number; y: number; width: number; height: number };
function expectContained(parent: Box, child: Box, label: string) {
const epsilon = 1;
expect(child.x, `${label} left edge`).toBeGreaterThanOrEqual(parent.x - epsilon);
expect(child.y, `${label} top edge`).toBeGreaterThanOrEqual(parent.y - epsilon);
expect(child.x + child.width, `${label} right edge`).toBeLessThanOrEqual(
parent.x + parent.width + epsilon,
);
expect(child.y + child.height, `${label} bottom edge`).toBeLessThanOrEqual(
parent.y + parent.height + epsilon,
);
}
async function readEditorContentMetrics(editor: Locator) {
return editor.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
const paddingLeft = Number.parseFloat(style.paddingLeft) || 0;
const paddingRight = Number.parseFloat(style.paddingRight) || 0;
const paddingTop = Number.parseFloat(style.paddingTop) || 0;
const paddingBottom = Number.parseFloat(style.paddingBottom) || 0;
return {
contentBox: {
x: rect.x + paddingLeft,
y: rect.y + paddingTop,
width: Math.max(0, rect.width - paddingLeft - paddingRight),
height: Math.max(0, rect.height - paddingTop - paddingBottom),
},
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
};
});
}
async function clearTaskCreateDrafts(page: Page) {
await page.evaluate(() => {
for (const key of Object.keys(window.sessionStorage)) {
@@ -21,6 +58,24 @@ async function clearTaskCreateDrafts(page: Page) {
});
}
async function openTaskCreateDialogAtWidth(page: Page, width: number) {
await page.setViewportSize({ width, height: 844 });
await page.goto("/");
await page.getByTestId("kanban-board").waitFor({ state: "visible" });
await clearTaskCreateDrafts(page);
const mobileFab = page.getByTestId("mobile-fab");
if (await mobileFab.isVisible()) {
await mobileFab.tap();
} else {
await page.getByTestId("create-task-button").first().click();
}
const dialog = page.getByTestId("create-task-dialog");
await expect(dialog).toBeVisible();
return { dialog, editor: dialog.getByTestId("task-description-input") };
}
test("edits saved-prompt chips and submits their aliases on mobile", async ({
testPage,
apiClient,
@@ -56,16 +111,29 @@ test("edits saved-prompt chips and submits their aliases on mobile", async ({
await option.tap();
await expectTaskDescription(editor, `@${PROMPT_NAME}`);
const chip = dialog.getByTestId("custom-prompt-mention").first();
const remove = dialog.getByTestId("task-prompt-reference-remove").first();
const reference = dialog.getByTestId("task-prompt-reference").first();
const chip = reference.getByTestId("custom-prompt-mention");
const remove = reference.getByTestId("task-prompt-reference-remove");
await expect(chip).toBeVisible();
await expect(remove).toBeVisible();
const [chipBox, removeBox] = await Promise.all([chip.boundingBox(), remove.boundingBox()]);
const [referenceBox, chipBox, removeBox] = await Promise.all([
reference.boundingBox(),
chip.boundingBox(),
remove.boundingBox(),
]);
expect(referenceBox).not.toBeNull();
expect(chipBox).not.toBeNull();
expect(removeBox).not.toBeNull();
expect(referenceBox!.height).toBeGreaterThanOrEqual(44);
expect(chipBox!.width).toBeGreaterThanOrEqual(44);
expect(chipBox!.height).toBeGreaterThanOrEqual(44);
expectContained(referenceBox!, chipBox!, "preview target");
expectContained(referenceBox!, removeBox!, "removal target");
expect(removeBox!.height).toBeGreaterThanOrEqual(44);
expect(removeBox!.width).toBeGreaterThanOrEqual(44);
const editorContentMetrics = await readEditorContentMetrics(editor);
expectContained(editorContentMetrics.contentBox, referenceBox!, "editor content");
expect(editorContentMetrics.scrollWidth).toBeLessThanOrEqual(editorContentMetrics.clientWidth);
await prCapture.screenshot("mobile-task-prompt-reference-chip", {
caption: "A saved prompt appears as an editable touch-sized chip in task creation.",
});
@@ -73,6 +141,7 @@ test("edits saved-prompt chips and submits their aliases on mobile", async ({
await chip.tap();
const drawer = testPage.locator('[data-slot="drawer-content"]:visible').last();
await expect(drawer).toBeVisible();
await expect(drawer.locator('[data-slot="drawer-description"]')).toHaveText("Prompt");
await expect(drawer).toContainText(PROMPT_CONTENT.slice(0, 45));
const previewScroll = drawer.locator("div.overflow-y-auto").first();
await expect(previewScroll).toBeVisible();
@@ -99,6 +168,7 @@ test("edits saved-prompt chips and submits their aliases on mobile", async ({
await expect(dialog.getByTestId("task-prompt-reference-remove")).toHaveCount(2);
await dialog.getByTestId("task-prompt-reference-remove").first().tap();
await expectTaskDescription(editor, ` and @${PROMPT_NAME}`);
await expect(testPage.locator('[data-slot="drawer-content"]:visible')).toHaveCount(0);
const longDraft = Array.from(
{ length: 36 },
@@ -120,7 +190,6 @@ test("edits saved-prompt chips and submits their aliases on mobile", async ({
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
const responsePromise = waitForHttp(testPage, "POST", /\/api\/v1\/tasks$/);
await dialog.getByTestId("submit-start-agent").tap();
const response = await responsePromise;
@@ -135,3 +204,101 @@ test("edits saved-prompt chips and submits their aliases on mobile", async ({
await apiClient.deletePrompt(prompt.id).catch(() => undefined);
}
});
test("long prompt chips remain usable across coarse-pointer widths", async ({
testPage,
apiClient,
prCapture,
}) => {
test.setTimeout(120_000);
const prompt = await apiClient.createPrompt(LONG_PROMPT_NAME, "Long mobile prompt content");
try {
for (const width of [390, 767, 768, 900]) {
const { dialog, editor } = await openTaskCreateDialogAtWidth(testPage, width);
await editor.fill("");
await editor.tap();
await editor.pressSequentially(`@${LONG_PROMPT_NAME}`);
const menu = testPage.getByRole("listbox", {
name: /Mention tasks, files, prompts/i,
});
await expect(menu).toBeVisible();
const promptOption = menu.getByRole("option", { name: LONG_PROMPT_NAME, exact: false });
await expect(promptOption).toBeVisible();
await promptOption.tap();
await expectTaskDescription(editor, `@${LONG_PROMPT_NAME}`);
await editor.focus();
await editor.press("ControlOrMeta+End");
await editor.pressSequentially(` and @${LONG_PROMPT_NAME}`);
await expect(menu).toBeVisible();
await expect(promptOption).toBeVisible();
await promptOption.tap();
const references = dialog.getByTestId("task-prompt-reference");
await expect(references).toHaveCount(2);
for (let index = 0; index < 2; index += 1) {
const reference = references.nth(index);
const mention = reference.getByTestId("custom-prompt-mention");
const label = mention.getByTestId("custom-prompt-mention-label");
const remove = reference.getByTestId("task-prompt-reference-remove");
await expect(mention).toHaveAttribute("aria-label", `Custom prompt: ${LONG_PROMPT_NAME}`);
await expect(label).toHaveText(`@${LONG_PROMPT_NAME}`);
const [referenceBox, mentionBox, labelBox, removeBox] = await Promise.all([
reference.boundingBox(),
mention.boundingBox(),
label.boundingBox(),
remove.boundingBox(),
]);
expect(referenceBox).not.toBeNull();
expect(mentionBox).not.toBeNull();
expect(labelBox).not.toBeNull();
expect(removeBox).not.toBeNull();
expect(mentionBox!.width).toBeGreaterThanOrEqual(44);
expect(mentionBox!.height).toBeGreaterThanOrEqual(44);
expect(removeBox!.width).toBeGreaterThanOrEqual(44);
expect(removeBox!.height).toBeGreaterThanOrEqual(44);
expect(mentionBox!.x).toBeGreaterThanOrEqual(referenceBox!.x - 1);
expect(mentionBox!.x + mentionBox!.width).toBeLessThanOrEqual(
referenceBox!.x + referenceBox!.width + 1,
);
expect(removeBox!.x).toBeGreaterThanOrEqual(referenceBox!.x - 1);
expect(removeBox!.x + removeBox!.width).toBeLessThanOrEqual(
referenceBox!.x + referenceBox!.width + 1,
);
expectContained(referenceBox!, labelBox!, `label ${index}`);
const labelMetrics = await label.evaluate((element) => {
const computed = getComputedStyle(element);
return {
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
overflow: computed.overflow,
textOverflow: computed.textOverflow,
whiteSpace: computed.whiteSpace,
};
});
expect(labelMetrics.overflow).toBe("hidden");
expect(labelMetrics.textOverflow).toBe("ellipsis");
expect(labelMetrics.whiteSpace).toBe("nowrap");
if (width === 390) {
expect(labelMetrics.scrollWidth).toBeGreaterThan(labelMetrics.clientWidth);
}
const editorMetrics = await readEditorContentMetrics(editor);
expectContained(editorMetrics.contentBox, referenceBox!, `editor content ${index}`);
expect(editorMetrics.scrollWidth).toBeLessThanOrEqual(editorMetrics.clientWidth);
}
expect(
await testPage.evaluate(() => document.documentElement.scrollWidth),
).toBeLessThanOrEqual(await testPage.evaluate(() => document.documentElement.clientWidth));
if (width === 390) {
await prCapture.screenshot("mobile-task-prompt-reference-long-name", {
caption: "Long saved-prompt references remain contained and touch-sized on a phone.",
});
}
}
} finally {
await apiClient.deletePrompt(prompt.id).catch(() => undefined);
}
});
@@ -3,10 +3,12 @@
* Complements task-create-prompt-autocomplete.spec.ts with edge-case coverage.
*/
import { test, expect } from "../../fixtures/test-base";
import type { Locator } from "@playwright/test";
import { KanbanPage } from "../../pages/kanban-page";
import { expectTaskDescription } from "../../pages/task-description-editor";
const MENU_TITLE = /Mention tasks, files, prompts/i;
const LONG_PROMPT_NAME = "qa-long-prompt-reference-name-that-definitely-overflows-editor-width";
async function cleanupPrompts(
apiClient: {
@@ -34,8 +36,84 @@ const ALL_QA_PROMPTS = [
"qa-back",
"qa-arrow",
"qa-submit",
"qa-compact",
LONG_PROMPT_NAME,
];
type Box = { x: number; y: number; width: number; height: number };
async function readPromptReferenceMetrics(reference: Locator) {
return reference.evaluate((element) => {
const readBox = (target: Element): Box => {
const rect = target.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
};
const mention = element.querySelector<HTMLElement>('[data-testid="custom-prompt-mention"]');
const remove = element.querySelector<HTMLElement>(
'[data-testid="task-prompt-reference-remove"]',
);
const label = mention?.querySelector<HTMLElement>(
'[data-testid="custom-prompt-mention-label"]',
);
if (!mention || !remove || !label) throw new Error("prompt reference controls are missing");
const elementsWithGreenBorder = [
element,
...Array.from(element.querySelectorAll<HTMLElement>("[class]")),
].filter((target) => target.getAttribute("class")?.includes("border-emerald-300/35"));
const labelStyle = getComputedStyle(label);
return {
shell: readBox(element),
mention: readBox(mention),
label: readBox(label),
remove: readBox(remove),
labelText: label.textContent ?? "",
labelClientWidth: label.clientWidth,
labelScrollWidth: label.scrollWidth,
labelFontSize: labelStyle.fontSize,
labelOverflow: labelStyle.overflow,
labelTextOverflow: labelStyle.textOverflow,
labelWhiteSpace: labelStyle.whiteSpace,
shellBorderWidth: getComputedStyle(element).borderTopWidth,
mentionBorderWidth: getComputedStyle(mention).borderTopWidth,
greenBorderCount: elementsWithGreenBorder.length,
};
});
}
async function readEditorContentMetrics(editor: Locator) {
return editor.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
const paddingLeft = Number.parseFloat(style.paddingLeft) || 0;
const paddingRight = Number.parseFloat(style.paddingRight) || 0;
const paddingTop = Number.parseFloat(style.paddingTop) || 0;
const paddingBottom = Number.parseFloat(style.paddingBottom) || 0;
return {
contentBox: {
x: rect.x + paddingLeft,
y: rect.y + paddingTop,
width: Math.max(0, rect.width - paddingLeft - paddingRight),
height: Math.max(0, rect.height - paddingTop - paddingBottom),
},
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
};
});
}
function expectContained(parent: Box, child: Box, label: string) {
const epsilon = 1;
expect(child.x, `${label} left edge`).toBeGreaterThanOrEqual(parent.x - epsilon);
expect(child.y, `${label} top edge`).toBeGreaterThanOrEqual(parent.y - epsilon);
expect(child.x + child.width, `${label} right edge`).toBeLessThanOrEqual(
parent.x + parent.width + epsilon,
);
expect(child.y + child.height, `${label} bottom edge`).toBeLessThanOrEqual(
parent.y + parent.height + epsilon,
);
}
test.describe("@-mention autocomplete: adversarial QA", () => {
test.afterEach(async ({ apiClient }) => {
await cleanupPrompts(apiClient, ALL_QA_PROMPTS);
@@ -293,4 +371,142 @@ test.describe("@-mention autocomplete: adversarial QA", () => {
timeout: 10_000,
});
});
test("editable prompt chip is compact and contains removal", async ({
testPage,
apiClient,
prCapture,
}) => {
test.setTimeout(90_000);
await apiClient.createPrompt("qa-compact", "Compact prompt content");
await testPage.setViewportSize({ width: 1280, height: 900 });
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.createTaskButton.first().click();
const dialog = testPage.getByTestId("create-task-dialog");
const editor = dialog.getByTestId("task-description-input");
await expect(dialog).toBeVisible();
await editor.fill("");
await editor.click();
await editor.pressSequentially("@qa-com");
await expect(testPage.getByText(MENU_TITLE)).toBeVisible();
const promptOption = testPage.getByRole("option").filter({ hasText: "qa-compact" });
await expect(promptOption).toBeVisible();
await promptOption.click();
await expect(dialog.getByTestId("task-prompt-reference")).toHaveCount(1);
const reference = dialog.getByTestId("task-prompt-reference").first();
const remove = reference.getByTestId("task-prompt-reference-remove");
const metrics = await readPromptReferenceMetrics(reference);
expect(metrics.shell.height).toBeCloseTo(24, 0);
expect(metrics.labelFontSize).toBe("12px");
expect(metrics.shellBorderWidth).toBe("1px");
expect(metrics.mentionBorderWidth).toBe("0px");
expect(metrics.greenBorderCount).toBe(1);
expectContained(metrics.shell, metrics.mention, "preview target");
expectContained(metrics.shell, metrics.remove, "removal target");
expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
await testPage.evaluate(() => document.documentElement.clientWidth),
);
const defaultBackground = await remove.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await remove.hover();
const hoverBackground = await remove.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
expect(hoverBackground).not.toBe(defaultBackground);
await remove.focus();
await expect(remove).toBeFocused();
const focusOutline = await remove.evaluate((element) => {
const style = getComputedStyle(element);
return { style: style.outlineStyle, width: style.outlineWidth };
});
expect(focusOutline.style).toBe("solid");
expect(focusOutline.width).toBe("2px");
await prCapture.screenshot("desktop-task-prompt-reference-chip", {
caption: "The desktop task prompt reference uses one compact border around both controls.",
});
await remove.press("Enter");
await expectTaskDescription(editor, "");
await expect(testPage.getByText("Compact prompt content", { exact: false })).toHaveCount(0);
await editor.press("ControlOrMeta+z");
await expectTaskDescription(editor, "@qa-compact");
await expect(dialog.getByTestId("task-prompt-reference")).toHaveCount(1);
for (const width of [767, 768]) {
await testPage.setViewportSize({ width, height: 900 });
await expect(reference).toBeVisible();
const resized = await readPromptReferenceMetrics(reference);
if (width < 768) {
expect(resized.shell.height).toBeGreaterThanOrEqual(44);
} else {
expect(resized.shell.height).toBeCloseTo(24, 0);
}
}
});
test("long prompt chips truncate and wrap without hiding removal", async ({
testPage,
apiClient,
prCapture,
}) => {
test.setTimeout(90_000);
const promptName = LONG_PROMPT_NAME;
await apiClient.createPrompt(promptName, "Long prompt content");
await testPage.setViewportSize({ width: 480, height: 900 });
const kanban = new KanbanPage(testPage);
await kanban.goto();
await testPage.getByTestId("mobile-fab").click();
const dialog = testPage.getByTestId("create-task-dialog");
const editor = dialog.getByTestId("task-description-input");
await expect(dialog).toBeVisible();
await editor.fill("");
await editor.click();
await editor.pressSequentially(`@${promptName}`);
await expect(testPage.getByText(MENU_TITLE)).toBeVisible();
const promptOption = testPage.getByRole("option").filter({ hasText: promptName });
await expect(promptOption).toBeVisible();
await promptOption.click();
await editor.focus();
await editor.press("ControlOrMeta+End");
await editor.pressSequentially(` and @${promptName}`);
await expect(testPage.getByText(MENU_TITLE)).toBeVisible();
await expect(promptOption).toBeVisible();
await promptOption.click();
const references = dialog.getByTestId("task-prompt-reference");
await expect(references).toHaveCount(2);
const editorMetrics = await readEditorContentMetrics(editor);
expect(editorMetrics.scrollWidth).toBeLessThanOrEqual(editorMetrics.clientWidth);
for (let index = 0; index < 2; index += 1) {
const reference = references.nth(index);
const mention = reference.getByTestId("custom-prompt-mention");
const remove = reference.getByTestId("task-prompt-reference-remove");
await expect(mention).toHaveAttribute("aria-label", `Custom prompt: ${promptName}`);
const metrics = await readPromptReferenceMetrics(reference);
expect(metrics.labelText).toBe(`@${promptName}`);
expect(metrics.labelScrollWidth).toBeGreaterThan(metrics.labelClientWidth);
expect(metrics.labelOverflow).toBe("hidden");
expect(metrics.labelTextOverflow).toBe("ellipsis");
expect(metrics.labelWhiteSpace).toBe("nowrap");
expectContained(editorMetrics.contentBox, metrics.shell, `editor content ${index}`);
expectContained(metrics.shell, metrics.mention, `preview target ${index}`);
expectContained(metrics.shell, metrics.label, `label ${index}`);
expectContained(metrics.shell, metrics.remove, `removal target ${index}`);
await expect(remove).toBeVisible();
}
expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
await testPage.evaluate(() => document.documentElement.clientWidth),
);
await prCapture.screenshot("desktop-task-prompt-reference-long-name", {
caption: "Long task prompt references keep their removal controls visible while truncating.",
});
});
});