From da204b8cdbb3bc9829969d599ef324c347754036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 14 Sep 2026 18:45:35 +0100 Subject: [PATCH] feat(ui): compact task prompt chips (#3665) --- .../task-prompt-reference-editor.test.tsx | 80 ++++- .../components/task-prompt-reference-node.tsx | 14 +- .../prompt-mention-components.round2.test.tsx | 95 +++++- .../messages/prompt-mention-components.tsx | 285 +++++++++++++----- .../mobile-task-create-prompt-chips.spec.ts | 177 ++++++++++- ...task-create-prompt-autocomplete-qa.spec.ts | 216 +++++++++++++ docs/plans/compact-task-prompt-chips/plan.md | 227 ++++++++++++++ .../task-01-compact-chips.md | 191 ++++++++++++ docs/plans/task-create-prompt-chips/plan.md | 4 + .../task-01-reference-editor.md | 4 + .../task-02-create-integration.md | 4 + .../ui/requirements/prompt-alias-rendering.md | 18 ++ .../system-design/prompt-alias-rendering.md | 75 ++++- 13 files changed, 1305 insertions(+), 85 deletions(-) create mode 100644 docs/plans/compact-task-prompt-chips/plan.md create mode 100644 docs/plans/compact-task-prompt-chips/task-01-compact-chips.md diff --git a/apps/web/components/task-prompt-reference-editor.test.tsx b/apps/web/components/task-prompt-reference-editor.test.tsx index 5eafbee633..7b17472fca 100644 --- a/apps/web/components/task-prompt-reference-editor.test.tsx +++ b/apps/web/components/task-prompt-reference-editor.test.tsx @@ -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 | 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("[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(), [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", () => { diff --git a/apps/web/components/task-prompt-reference-node.tsx b/apps/web/components/task-prompt-reference-node.tsx index 1f02892423..4150d89a41 100644 --- a/apps/web/components/task-prompt-reference-node.tsx +++ b/apps/web/components/task-prompt-reference-node.tsx @@ -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", + )} > - + + + + + {label} + {description} + +
+ +
+
+ + ); +} + +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 ( + + + + + + + + + + + ); +} + 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 ( - - {value} - + ); } 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 ( - - - - - - - {label} - {label} - -
- -
-
-
- ); + return ; } return ( - - - - {value} - - - - - - + setOpen((isOpen) => !isOpen)} + /> ); } diff --git a/apps/web/e2e/tests/task/mobile-task-create-prompt-chips.spec.ts b/apps/web/e2e/tests/task/mobile-task-create-prompt-chips.spec.ts index 0a306951f8..016624d5b4 100644 --- a/apps/web/e2e/tests/task/mobile-task-create-prompt-chips.spec.ts +++ b/apps/web/e2e/tests/task/mobile-task-create-prompt-chips.spec.ts @@ -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); + } +}); diff --git a/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts b/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts index eee0f7c364..8fae70f1ee 100644 --- a/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts +++ b/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts @@ -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('[data-testid="custom-prompt-mention"]'); + const remove = element.querySelector( + '[data-testid="task-prompt-reference-remove"]', + ); + const label = mention?.querySelector( + '[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("[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.", + }); + }); }); diff --git a/docs/plans/compact-task-prompt-chips/plan.md b/docs/plans/compact-task-prompt-chips/plan.md new file mode 100644 index 0000000000..6f5e5d974d --- /dev/null +++ b/docs/plans/compact-task-prompt-chips/plan.md @@ -0,0 +1,227 @@ +--- +created: 2026-09-14 +status: implemented +requirements: + - REQ-UI-PROMPT-ALIAS-002 +system_design: + - ../../specs/ui/system-design/prompt-alias-rendering.md +legacy_specs: [] +--- + +# Implementation plan: Compact task-create prompt chips + +## Overview + +Make editable saved-prompt references smaller and enclose their removal button +inside the green chip. The user accepted the compact ASCII direction on 2026-09-14. +One work order delivers presentation and regression coverage together. +UI owns the existing reusable alias presentation contract. + +## Scope + +### In scope + +- Desktop density, one enclosing border, separate preview/removal targets. +- Truncation, wrapping, focus feedback, and phone/coarse-pointer sizing. +- Regression evidence for editing, previews, undo, and alias serialization. + +### Out of scope + +- Transcript/history restyling, other chip families, toolbar or dialog redesign. +- Removing user-authored blank lines, changing prompt expansion or persistence. +- Backend changes, new packages, runtime flags, and delegation. + +## Technical approach + +Follow [Compact editable reference presentation](../../specs/ui/system-design/prompt-alias-rendering.md#compact-editable-reference-presentation). +`TaskPromptReferenceView` owns the enclosing shell and occurrence removal. +An opt-in presentation on `PromptMentionChip` reuses preview state and content. +Keep its read-only defaults unchanged. Use the existing responsive hooks and +semantic sibling actions. No global chip-size or editor-font change is needed. + +This follows the completed [task-create package](../task-create-prompt-chips/plan.md). +Its historical results remain valid for that implementation; this package owns +new sizing/containment evidence. The [transcript package](../prompt-alias-rendering/plan.md) +remains unchanged because its layout and contract are outside this change. + +## ASCII UI preview + +### UI-01: Task creation, desktop reference + +Current source and supplied screenshot: `[@fix-repo-issue] x`. +Proposed region: + +```text ++-----------------------------------------------------+ +| https://github.com/kdlbs/kandev/issues/3663 | +| | +| [ @fix-repo-issue x ] | +| | +| [Attach] [Enhance] | ++-----------------------------------------------------+ +``` + +One green border encloses preview and removal. Desktop outer height is 24px, +label size is 12px, and the close icon stays visible. Existing draft newlines +are preserved; drawing whitespace does not prescribe added margins. +Maps to `.3`, `.4`, and `.8`; desktop geometry and keyboard checks prove it. + +### UI-02: Phone creation, reference and preview + +```text ++------------------------------+ +| New task | +| Goal text | +| +--------------------------+ | +| | @fix-repo-issue x | | +| +--------------------------+ | +| [Attach] [Enhance] | +| ... existing form scroll ... | +| [Cancel] [Start task] | ++------------------------------+ +Tap label -> existing preview drawer +Tap x -> remove this occurrence +``` + +Both targets are at least 44px square, inside one border. This sizing also +applies to coarse-pointer tablets. Preview keeps its fixed header and internal +safe-area-aware scroll area; dismissal returns to the same draft. +Form/footer and editor scroll ownership follow the existing creation surface. +Maps to `.4`, `.6`, and `.10`; mobile geometry and interaction checks prove it. + +### UI-03: Long names and repeated references, both viewports + +```text +[ @a-very-long-prompt-na... x ] +[ @fix-repo-issue x ] [ @fix-repo-issue x ] +``` + +A name truncates before the fixed removal target; complete chips wrap when the +next chip cannot fit. The full name remains accessible. Removing one occurrence +retains the other and surrounding text; undo restores it. With no references, +only the original editable text remains, with no empty chip row. +Maps to `.3`, `.9`, and `.10`; long-name, duplicate, and removal tests prove it. + +Grouping, order, containment, and pointer-specific target sizes are required. +ASCII spacing and example content are illustrative. Product labels stay localized. + +## Tests + +All criterion suffixes below refer to `AC-UI-PROMPT-ALIAS-002`. + +| Criteria | Evidence | +| --- | --- | +| `.3`, `.4`, `.10` | Extend `components/task-prompt-reference-editor.test.tsx`, including existing `removes only the selected occurrence through its visible action`, with removal isolation, keyboard activation, no submission/preview, and undo preservation. | +| `.3` | Existing `lib/prompts/task-prompt-document.test.ts` preserves exact plain-text serialization. | +| `.4`, `.10` | Existing `components/task/chat/messages/prompt-mention-components.round2.test.tsx` protects default shared preview behavior; add opt-in versus default composition assertions. | + +Use TDD for changed behavior. CSS dimensions require browser evidence, not +jsdom class-name snapshots. Do not add unrelated test suites. + +## E2E tests + +Extend these existing files. Proposed test titles identify implementation deliverables. + +| Project / file | Scenario and criteria | +| --- | --- | +| chromium / `tests/task/task-create-prompt-autocomplete-qa.spec.ts` | `editable prompt chip is compact and contains removal`: outer height 24px within 1px, computed label size 12px, enclosing border contains both controls; hover/focus and keyboard removal/undo. `.3`, `.4`, `.8`, `.10`. | +| chromium / same file | `long prompt chips truncate and wrap without hiding removal`: very long and repeated names, full accessible labels, no target overlap or horizontal overflow. `.9`. | +| chromium / `tests/task/task-create-prompt-autocomplete.spec.ts` and QA file | Existing selection/submission tests retain literal aliases and normal editor behavior. `.3`, `.4`. | +| chromium / `tests/chat/chat-prompt-mention-hover.spec.ts` | Shared read-only chip preview regression; default dimensions/style receive component evidence. `.10`. | +| mobile-chrome / `tests/task/mobile-task-create-prompt-chips.spec.ts` | Extend `edits saved-prompt chips and submits their aliases on mobile`: enclosing-border containment, both target widths/heights at least 44px, tap preview/dismiss/remove, unchanged alias payload. `.3`, `.4`, `.6`, `.10`. | +| mobile-chrome / same file | `long prompt chips remain usable across touch widths`: canonical project phone viewport plus 767px, 768px, and 900px coarse-pointer widths. Check computed sizing, label truncation, no overlap/overflow, and reachable removal. `.6`, `.9`, `.10`. | + +Desktop geometry checks also run at 767px and 768px with fine pointers to prove +phone sizing switches to desktop sizing at the canonical boundary. Use the +existing Pixel 5 project for mobile; no per-test device replacement. +Seed disposable prompts and clean up in `finally`. Scope selectors to the open +create dialog and individual reference occurrence. Capture and inspect desktop, +phone, long-name, and focused-control screenshots against UI-01 through UI-03. + +## Work orders + +- [x] [Task 01: Compact editable prompt chips](task-01-compact-chips.md) + +Wave 1, no dependencies, sequential execution. No implementation is authorized +by creation of this package. + +## Verification commands + +Run from repository root in sequence. Managed E2E commands rebuild the production +web/backend and provide isolated runtime data. Do not use `--no-build` after edits. + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/web && pnpm exec vitest run components/task-prompt-reference-editor.test.tsx components/task/chat/messages/prompt-mention-components.round2.test.tsx lib/prompts/task-prompt-document.test.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm exec eslint components/task-prompt-reference-node.tsx components/task/chat/messages/prompt-mention-components.tsx components/task-prompt-reference-editor.test.tsx e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts e2e/tests/task/mobile-task-create-prompt-chips.spec.ts) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +(cd apps/web && pnpm run e2e:sleep-ratchet) +(cd apps/web && pnpm e2e:run --host --project chromium tests/task/task-create-prompt-autocomplete.spec.ts tests/task/task-create-prompt-autocomplete-qa.spec.ts tests/chat/chat-prompt-mention-hover.spec.ts --retries=0) +(cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/task/mobile-task-create-prompt-chips.spec.ts --retries=0) +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.test.py +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +## Verification results + +Implementation validation on 2026-09-14: + +- TDD RED recorded a failing compact/touch geometry regression before the implementation; the GREEN focused Vitest run passed 3 files and 33 tests. +- `pnpm run typecheck`: passed. +- Targeted ESLint for the changed production, unit-test, and E2E files: passed. +- `pnpm run i18n:check`, `pnpm run i18n:ratchet`, and `pnpm run e2e:sleep-ratchet`: passed. +- The required desktop managed E2E suite passed 16 tests. +- The required mobile managed E2E suite passed 2 tests. +- `pnpm run build`: passed. The build emitted Vite advisory warnings about deprecated chunk configuration, large chunks, and ineffective dynamic imports, but no errors. +- `python3 scripts/list-docs.py validate`, `python3 scripts/lint-spec-files.test.py`, `python3 scripts/lint-spec-files.py --all`, and `git diff --check`: passed. +- Additional capture runs passed with `CAPTURE_PR_ASSETS=1`; desktop and mobile compact, preview, and long-name screenshots were inspected against UI-01 through UI-03. +- Production and permanent test files were changed only within the scoped frontend implementation and regression coverage; no backend or locale catalog changes were needed. + +Review remediation on 2026-09-14: + +- Restored the established 44px coarse-pointer target for default read-only prompt previews while leaving fine-pointer defaults unchanged. +- Constrained the editable shell with border-box sizing and checked its bounds against the editor content box and editor scroll metrics. +- Added an inner truncation label for editable triggers and fallbacks, with long-name checks that verify actual label overflow. +- Focused Vitest regressions passed 3 files and 35 tests after the corrections. +- Typecheck, targeted ESLint, i18n checks and ratchet, E2E sleep ratchet, and `git diff --check` passed after the corrections. +- Fresh managed desktop and mobile capture suites passed 16 and 2 tests; their compact, preview, and long-name screenshots were inspected after the corrections. + +PR fixup remediation on 2026-09-14: + +- Merged the current `main` base before the fixup so the PR does not remove + delivery artifacts added after the original branch point. +- Split touch and hover preview prop contracts, used the existing localized + `task:prompt` description for the touch drawer, and made mobile containment + assertions cover both axes for both actionable children. +- Guarded the touch preview trigger's pointer press so dismissing its drawer + does not leave the atom selected when the user continues typing. +- Focused Vitest regressions passed 3 files and 35 tests; typecheck and the + targeted ESLint command passed. +- The mobile managed E2E suite passed 2 tests after the fixup changes. + +Design validation on 2026-09-14: + +- `python3 scripts/list-docs.py validate`: passed, 267 decisions and 902 specifications. +- `python3 scripts/lint-spec-files.test.py`: 36 tests passed. +- `python3 scripts/lint-spec-files.py --all`: passed. +- `git diff --check`: passed. +- `git status --short -- docs/plans/compact-task-prompt-chips`: confirmed both new artifacts. +- The catalog discovers the owning requirement/design pair. Work-order IDs and + design paths resolve. Implementation and rendered checks completed in the + implementation turn. + +## Risks + +- Shared presentation overrides could change read-only chips if defaults leak. +- Borders and flex sizing can reduce a nominal 44px child or clip long labels. +- Event bubbling can open preview while removing a reference or submit a form. +- Inline node resizing must not alter draft whitespace or undo behavior. + +## Documentation impact + +Internal specifications and plans change. Public docs describe preview and removal +without prescribing control geometry; no public copy or navigation changes are planned. diff --git a/docs/plans/compact-task-prompt-chips/task-01-compact-chips.md b/docs/plans/compact-task-prompt-chips/task-01-compact-chips.md new file mode 100644 index 0000000000..e905e07c8b --- /dev/null +++ b/docs/plans/compact-task-prompt-chips/task-01-compact-chips.md @@ -0,0 +1,191 @@ +--- +id: 01-compact-chips +title: Compact editable prompt chips +status: done +wave: 1 +depends_on: [] +plan: plan.md +requirements: + - REQ-UI-PROMPT-ALIAS-002 +acceptance_criteria: + - AC-UI-PROMPT-ALIAS-002.3 + - AC-UI-PROMPT-ALIAS-002.4 + - AC-UI-PROMPT-ALIAS-002.6 + - AC-UI-PROMPT-ALIAS-002.8 + - AC-UI-PROMPT-ALIAS-002.9 + - AC-UI-PROMPT-ALIAS-002.10 +system_design: + - ../../specs/ui/system-design/prompt-alias-rendering.md +--- + +# Task 01: Compact editable prompt chips + +## Summary + +Implement the compact enclosing chip and prove desktop and touch editing behavior. +Read the [requirements](../../specs/ui/requirements/prompt-alias-rendering.md) +and [design](../../specs/ui/system-design/prompt-alias-rendering.md#compact-editable-reference-presentation). + +## Scope + +Own node-view presentation, opt-in shared preview styling, and focused tests. +Preserve all existing editor handlers, plain-text contracts, and shared defaults. +Implement the complete scenario matrix in the [plan](plan.md#e2e-tests). + +## Exclusions + +No backend, persistence, prompt matching, expansion, other chip families, +editor-wide fonts/padding, toolbar layout, or transcript restyling. + +## Acceptance + +1. Rendered desktop/phone geometry and interaction match UI-01 through UI-03. +2. Removal, preview, keyboard operation, undo, and payload regressions pass. +3. All targeted checks pass; record results and screenshots before marking done. + +## ASCII UI preview + +These are the relevant views from the [combined preview](plan.md#ascii-ui-preview). + +### UI-01: Task creation, desktop reference + +Current source and supplied screenshot: `[@fix-repo-issue] x`. +Proposed region: + +```text ++-----------------------------------------------------+ +| https://github.com/kdlbs/kandev/issues/3663 | +| | +| [ @fix-repo-issue x ] | +| | +| [Attach] [Enhance] | ++-----------------------------------------------------+ +``` + +One green border encloses preview and removal. Desktop outer height is 24px, +label size is 12px, and the close icon stays visible. Existing draft newlines +are preserved; drawing whitespace does not prescribe added margins. +Maps to `.3`, `.4`, and `.8`; desktop geometry and keyboard checks prove it. + +### UI-02: Phone creation, reference and preview + +```text ++------------------------------+ +| New task | +| Goal text | +| +--------------------------+ | +| | @fix-repo-issue x | | +| +--------------------------+ | +| [Attach] [Enhance] | +| ... existing form scroll ... | +| [Cancel] [Start task] | ++------------------------------+ +Tap label -> existing preview drawer +Tap x -> remove this occurrence +``` + +Both targets are at least 44px square, inside one border. This sizing also +applies to coarse-pointer tablets. Preview keeps its fixed header and internal +safe-area-aware scroll area; dismissal returns to the same draft. +Form/footer and editor scroll ownership follow the existing creation surface. +Maps to `.4`, `.6`, and `.10`; mobile geometry and interaction checks prove it. + +### UI-03: Long names and repeated references, both viewports + +```text +[ @a-very-long-prompt-na... x ] +[ @fix-repo-issue x ] [ @fix-repo-issue x ] +``` + +A name truncates before the fixed removal target; complete chips wrap when the +next chip cannot fit. The full name remains accessible. Removing one occurrence +retains the other and surrounding text; undo restores it. With no references, +only the original editable text remains, with no empty chip row. +Maps to `.3`, `.9`, and `.10`; long-name, duplicate, and removal tests prove it. + +Grouping, order, containment, and pointer-specific target sizes are required. +ASCII spacing and example content are illustrative. Product labels stay localized. + +## Likely files + +- `apps/web/components/task-prompt-reference-node.tsx`. +- `apps/web/components/task/chat/messages/prompt-mention-components.tsx`. +- `apps/web/components/task-prompt-reference-editor.test.tsx`. +- `apps/web/components/task/chat/messages/prompt-mention-components.round2.test.tsx`. +- `apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts`. +- `apps/web/e2e/tests/task/mobile-task-create-prompt-chips.spec.ts`. +- This plan, work order, and paired design for accurate delivery results. + +Reuse existing translated accessible labels. If new copy proves necessary, +update all locale catalogs under the repository i18n workflow and add those +paths to the work order before completion. + +## Dependencies + +None. Existing task-create prompt-reference implementation is present. +Execute in the primary session after an explicit implementation request. + +## Verification + +Use TDD: first record a defect-specific failing geometry or interaction assertion, +then implement and rerun. Missing test selectors alone do not prove the defect. +Follow the plan's named tests and screenshot checks. Run from repository root: + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/web && pnpm exec vitest run components/task-prompt-reference-editor.test.tsx components/task/chat/messages/prompt-mention-components.round2.test.tsx lib/prompts/task-prompt-document.test.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm exec eslint components/task-prompt-reference-node.tsx components/task/chat/messages/prompt-mention-components.tsx components/task-prompt-reference-editor.test.tsx e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts e2e/tests/task/mobile-task-create-prompt-chips.spec.ts) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +(cd apps/web && pnpm run e2e:sleep-ratchet) +(cd apps/web && pnpm e2e:run --host --project chromium tests/task/task-create-prompt-autocomplete.spec.ts tests/task/task-create-prompt-autocomplete-qa.spec.ts tests/chat/chat-prompt-mention-hover.spec.ts --retries=0) +(cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/task/mobile-task-create-prompt-chips.spec.ts --retries=0) +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.test.py +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +## Risks + +Keep the close control outside the preview trigger's interactive DOM subtree. +Do not let shared chip classes override compact sizing or leak into read-only +consumers. Verify actual border and target bounds, including long names. + +## Results + +Completed 2026-09-14. + +- TDD RED recorded a failing compact/touch geometry regression before the implementation; the GREEN focused Vitest run passed 3 files and 33 tests. +- `pnpm run typecheck`: passed. +- Targeted ESLint for the changed production, unit-test, and E2E files: passed. +- `pnpm run i18n:check`, `pnpm run i18n:ratchet`, and `pnpm run e2e:sleep-ratchet`: passed. +- The required desktop managed E2E suite passed 16 tests. +- The required mobile managed E2E suite passed 2 tests. +- `pnpm run build`: passed. The build emitted Vite advisory warnings about deprecated chunk configuration, large chunks, and ineffective dynamic imports, but no errors. +- `python3 scripts/list-docs.py validate`, `python3 scripts/lint-spec-files.test.py`, `python3 scripts/lint-spec-files.py --all`, and `git diff --check`: passed. +- Additional capture runs passed with `CAPTURE_PR_ASSETS=1`; desktop and mobile compact, preview, and long-name screenshots were inspected against UI-01 through UI-03. +- No backend or locale catalog changes were needed. + +Review remediation on 2026-09-14: + +- Restored the established 44px coarse-pointer target for default read-only prompt previews while leaving fine-pointer defaults unchanged. +- Constrained the editable shell with border-box sizing and checked its bounds against the editor content box and editor scroll metrics. +- Added an inner truncation label for editable triggers and fallbacks, with long-name checks that verify actual label overflow. +- Focused Vitest regressions passed 3 files and 35 tests after the corrections. +- Typecheck, targeted ESLint, i18n checks and ratchet, E2E sleep ratchet, and `git diff --check` passed after the corrections. +- Fresh managed desktop and mobile capture suites passed 16 and 2 tests; their compact, preview, and long-name screenshots were inspected after the corrections. + +PR fixup remediation on 2026-09-14: + +- Merged the current `main` base before the fixup so the PR does not remove + delivery artifacts added after the original branch point. +- Split touch and hover preview prop contracts, used the existing localized + `task:prompt` description for the touch drawer, and made mobile containment + assertions cover both axes for both actionable children. +- Guarded the touch preview trigger's pointer press so dismissing its drawer + does not leave the atom selected when the user continues typing. +- Focused Vitest regressions passed 3 files and 35 tests; typecheck and the + targeted ESLint command passed. +- The mobile managed E2E suite passed 2 tests after the fixup changes. diff --git a/docs/plans/task-create-prompt-chips/plan.md b/docs/plans/task-create-prompt-chips/plan.md index 4c94b84f4b..d9b9883c5a 100644 --- a/docs/plans/task-create-prompt-chips/plan.md +++ b/docs/plans/task-create-prompt-chips/plan.md @@ -55,6 +55,10 @@ test helpers for create-mode contenteditable semantics without changing session ## ASCII UI preview +Historical preview from the completed implementation. The +[compact-chip follow-up](../compact-task-prompt-chips/plan.md) now proposes +one enclosing border and revised sizing; it owns new geometry verification. + ### UI-01: Desktop create dialog, recognized preset Current input: plain goal followed by literal `@create-canvas`. diff --git a/docs/plans/task-create-prompt-chips/task-01-reference-editor.md b/docs/plans/task-create-prompt-chips/task-01-reference-editor.md index d9f3620709..f10dcb27c0 100644 --- a/docs/plans/task-create-prompt-chips/task-01-reference-editor.md +++ b/docs/plans/task-create-prompt-chips/task-01-reference-editor.md @@ -44,6 +44,10 @@ remains plain text, with recognized references as removable inline chips. ## ASCII UI preview +Historical preview from the completed implementation. The +[compact-chip follow-up](../compact-task-prompt-chips/plan.md) now proposes +one enclosing border and revised sizing; it owns new geometry verification. + UI-01/02 excerpt from the [full preview](plan.md#ascii-ui-preview), criteria `.1` to `.6`: ```text diff --git a/docs/plans/task-create-prompt-chips/task-02-create-integration.md b/docs/plans/task-create-prompt-chips/task-02-create-integration.md index d158cb7585..648634f0fd 100644 --- a/docs/plans/task-create-prompt-chips/task-02-create-integration.md +++ b/docs/plans/task-create-prompt-chips/task-02-create-integration.md @@ -45,6 +45,10 @@ Preserve shared form services and prove the complete desktop and phone interacti ## ASCII UI preview +Historical preview from the completed implementation. The +[compact-chip follow-up](../compact-task-prompt-chips/plan.md) now proposes +one enclosing border and revised sizing; it owns new geometry verification. + UI-01/02 excerpt from the [full preview](plan.md#ascii-ui-preview), criteria `.1` through `.7`: ```text diff --git a/docs/specs/ui/requirements/prompt-alias-rendering.md b/docs/specs/ui/requirements/prompt-alias-rendering.md index 044e1df7d3..730bdebfbf 100644 --- a/docs/specs/ui/requirements/prompt-alias-rendering.md +++ b/docs/specs/ui/requirements/prompt-alias-rendering.md @@ -94,6 +94,22 @@ This extends the reusable alias presentation contract beyond transcript views. New Agent, task editing, and other prompt editors shall retain their existing insertion behavior. Backend expansion authority and passthrough exclusions shall remain unchanged. + +- **AC-UI-PROMPT-ALIAS-002.8:** On fine-pointer desktop at the standard root font, + editable reference chips shall have a 24px outer height and a 12px label. + One green background and border shall enclose the label and always-visible + removal control. The control shall show hover and keyboard-focus feedback. + Chip layout shall add no separate row margin around the reference. +- **AC-UI-PROMPT-ALIAS-002.9:** Long reference names shall truncate inside the + available editor width while the removal control remains fully visible. + The full name shall remain accessible. Multiple chips shall wrap as whole + units without horizontal document overflow or overlapping action targets. +- **AC-UI-PROMPT-ALIAS-002.10:** On phones and coarse-pointer devices, preview + and removal targets shall each measure at least 44px in both dimensions. + Both controls shall stay inside the same chip border with distinct hit areas. + Activating removal shall not open a preview or submit the form. + Desktop transcript, pinned-prompt, and history chip sizing shall remain unchanged. + ## Out of scope - Changing prompt alias parsing, matching, expansion depth, or agent delivery. @@ -105,3 +121,5 @@ This extends the reusable alias presentation contract beyond transcript views. - [Transcript alias rendering](../../../plans/prompt-alias-rendering/plan.md) - [Task-create reference chips](../../../plans/task-create-prompt-chips/plan.md) + +- [Compact task-create prompt chips](../../../plans/compact-task-prompt-chips/plan.md) diff --git a/docs/specs/ui/system-design/prompt-alias-rendering.md b/docs/specs/ui/system-design/prompt-alias-rendering.md index 0225d9dcdc..89bde669d0 100644 --- a/docs/specs/ui/system-design/prompt-alias-rendering.md +++ b/docs/specs/ui/system-design/prompt-alias-rendering.md @@ -181,7 +181,9 @@ The drawer fits this temporary inspection task without adding a navigation step. Chips wrap, and very long names stay within editor bounds with accessible full names. Touch preview/removal actions meet the 44px hit-area minimum without enlarging desktop controls. Use `useResponsiveBreakpoint` for layout and `useTouchDrawer` -for disclosure, including coarse-pointer tablets. +for disclosure, including coarse-pointer tablets. The touch drawer exposes a +distinct localized description for assistive technology instead of repeating +its title. The form body retains outer scrolling and its existing reachable footer. The editor retains bounded internal scrolling for long drafts, with the caret visible. @@ -200,3 +202,74 @@ serialization, editing, integration, and rendered desktop/phone checks for crite Tests must prove task payloads, not infer serialization from chip appearance. Related decision: [Server-owned expansion](../../../decisions/2026-09-01-server-owned-saved-prompt-expansion.md). + + +## Compact editable reference presentation + +This draft extension implements criteria `AC-UI-PROMPT-ALIAS-002.8` through +`.10`, preserving `.3`, `.4`, and `.6`. UI owns this change because it extends +reusable alias presentation, without changing task state or prompt delivery. + +### Current source and proposed composition + +`TaskPromptReferenceView` in `apps/web/components/task-prompt-reference-node.tsx` +currently renders `PromptMentionChip` beside a separate remove button. +The chip owns its green border; removal has a 28px desktop box outside it. +`PromptMentionChip` also serves transcript and history consumers. + +Add a narrowly scoped, opt-in editable presentation to `PromptMentionChip`. +The node view owns the shared green outer shell and its remove button. +The opt-in preview trigger inherits the shell's color and has no second border, +background, or outer padding. Default read-only chip rendering stays unchanged. +Default read-only touch preview triggers retain their established 44px target; +fine-pointer defaults retain their ordinary chip sizing. +Use sibling preview and remove controls, never nested interactive elements. +Keep prompt lookup, HoverCard, Drawer, and their handlers in the existing chip. +Apply the editable styling in the content-empty fallback as well. + +At the standard 16px root font, use a 1.5rem desktop outer height and 0.75rem +label, retaining the current emerald theme tokens and rounded corners. +Account for borders when sizing children so the outer height stays 24px. +Use a small close icon, visible muted ink, hover feedback, and a focus-visible +outline. Keep removal's translated `task:removeLabeled` name and current test IDs. +Use border-box sizing, `min-w-0`, and an inner text-formatting box with a +shrinkable ellipsis label. Keep a non-shrinking remove control. The shell fits +within the editor's available content box; neither control can overlap the +other. +The label's accessible name retains the full alias even when visually truncated. + +No new row wrapper, margin, or blank paragraph is inserted around a chip. +The smaller inline node reduces its own line footprint; user-authored blank +lines, editor padding, and toolbar spacing remain unchanged. + +### Responsive and interaction contract + +Use `useResponsiveBreakpoint` for phone sizing below 768px and combine it with +coarse-pointer detection for touch-sized controls. `useTouchDrawer` remains the +preview-mode authority, including coarse-pointer tablets and wider touch screens. +On phone or coarse-pointer devices, each actionable child is at least 44px square; +the outer shell includes those hit areas plus its border. Desktop sizing is the base. +Do not simulate a 44px target with overlapping invisible padding outside a 24px shell. + +The nearest shipped exemplars are `TaskCreateDialog` for the phone creation +surface and `PromptMentionChip` for temporary preview through a touch Drawer. +Keep the inline label-first composition because references belong in editable text. +The close action removes one occurrence; the label previews it. Phone entry remains +New task through the existing FAB, in the full-height creation form. +The existing form body and bounded editor retain their scroll responsibilities. +The preview drawer retains its safe-area-aware scroll body. No navigation changes. + +Preserve the node's `deleteNode` path, event propagation guards, selection, +plain-text serialization, and undo history. Preview triggers guard their pointer +press so opening and dismissing a touch preview does not leave the atom selected +for replacement by the next typed character. Preview dismissal returns to the +composer without changing the draft. Removal never triggers preview or submission. +No new store, backend, persistence, API, permissions, dependencies, or telemetry. + +### Validation and delivery + +The [compact-chip plan](../../../plans/compact-task-prompt-chips/plan.md) owns +focused component and desktop/phone rendered tests. Assert outer dimensions, +shared-border containment, separate targets, computed label size, long-name +truncation, focus feedback, occurrence removal, undo, and alias payloads. +Retain shared transcript preview regressions to detect default-style leakage.