feat(ui): compact task prompt chips (#3665)
This commit is contained in:
@@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user