diff --git a/tests/storybook-visual/composer-skills.spec.ts b/tests/storybook-visual/composer-skills.spec.ts new file mode 100644 index 00000000000..9f7352a8e9d --- /dev/null +++ b/tests/storybook-visual/composer-skills.spec.ts @@ -0,0 +1,118 @@ +import { expect, test } from "@playwright/test"; + +for (const surface of ["composer-new-task", "composer-skills"]) { + for (const theme of ["dark", "light"]) { + for (const mobile of [false, true]) { + for (const state of ["slash-commands", "inline-skill"]) { + test(`${surface} ${state} ${theme} ${mobile ? "mobile" : "desktop"}`, async ({ page }, testInfo) => { + await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1200, height: 800 }); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + const id = `${surface}--${mobile ? "mobile-" : ""}${state}`; + await page.goto(`/iframe.html?id=${id}&viewMode=story&globals=theme:${theme}`); + await expect.poll(() => page.evaluate(() => { + const channel = (window as unknown as { + __STORYBOOK_ADDONS_CHANNEL__?: { data: Record> }; + }).__STORYBOOK_ADDONS_CHANNEL__; + return channel?.data.storyFinished?.[0]?.status; + }), { timeout: 30_000 }).toBe("success"); + expect(await page.evaluate(() => (window as unknown as { + __STORYBOOK_ADDONS_CHANNEL__?: { data: Record }; + }).__STORYBOOK_ADDONS_CHANNEL__?.data.playFunctionThrewException)).toBeUndefined(); + expect(errors).toEqual([]); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + const editor = page.getByRole("textbox", { name: "editable markdown" }); + const editorFont = await editor.evaluate((element) => getComputedStyle(element).fontSize); + const expectedFont = await page.evaluate((isMobile) => { + const token = isMobile ? "--text-base" : "--text-sm"; + const probe = document.createElement("span"); + probe.style.fontSize = `var(${token})`; + document.body.append(probe); + const size = getComputedStyle(probe).fontSize; + probe.remove(); + return size; + }, mobile); + expect(editorFont).toBe(expectedFont); + + if (state === "slash-commands") { + const menu = page.getByTestId("mention-autocomplete-menu"); + await expect(menu).toBeVisible(); + const bounds = await menu.boundingBox(); + const composer = await page.locator(".paperclip-task-chat-composer").boundingBox(); + expect(bounds!.width).toBeCloseTo(composer!.width, 0); + expect(bounds!.x).toBeCloseTo(composer!.x, 0); + expect(bounds!.x).toBeGreaterThanOrEqual(0); + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(page.viewportSize()!.width); + expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(page.viewportSize()!.height); + const description = menu.getByText(/^Walk through the feature/); + const layout = await description.evaluate((element) => { + const style = getComputedStyle(element); + return { clipped: element.scrollWidth > element.clientWidth, whiteSpace: style.whiteSpace, overflow: style.textOverflow }; + }); + expect(layout).toEqual({ clipped: true, whiteSpace: "nowrap", overflow: "ellipsis" }); + const labelBounds = await menu.getByText("/test-it-for-real", { exact: true }).boundingBox(); + const descriptionBounds = await description.boundingBox(); + expect(descriptionBounds!.x).toBeGreaterThan(labelBounds!.x + labelBounds!.width); + expect(Math.abs((descriptionBounds!.y + descriptionBounds!.height / 2) + - (labelBounds!.y + labelBounds!.height / 2))).toBeLessThan(2); + await expect(menu.getByRole("button", { name: /\/release-notes/ })).toBeVisible(); + const uncutName = menu.getByText("/release-notes-for-project", { exact: true }); + expect(await uncutName.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); + } else { + const skill = page.getByRole("textbox", { name: "editable markdown" }).locator('[data-mention-kind="skill"]'); + await expect(page.getByRole("textbox", { name: "editable markdown" })).toHaveText("Use /test-it-for-real to verify the sign-in redirect."); + await expect(skill).toBeVisible(); + const appearance = await skill.evaluate((element) => { + const style = getComputedStyle(element); + const icon = getComputedStyle(element, "::before"); + return { + border: style.borderTopWidth, padding: style.padding, radius: style.borderRadius, + color: style.color, parentColor: getComputedStyle(element.parentElement!).color, + mask: icon.maskImage, iconWidth: Number.parseFloat(icon.width), + }; + }); + expect(appearance.border).toBe("0px"); + expect(appearance.padding).toBe("0px"); + expect(appearance.radius).toBe("0px"); + expect(appearance.color).not.toBe(appearance.parentColor); + expect(appearance.mask).toContain("data:image/svg+xml"); + expect(appearance.iconWidth).toBeGreaterThan(0); + const heights = await skill.evaluate((element) => { + const paragraph = element.closest("p")!; + const height = paragraph.getBoundingClientRect().height; + const plain = paragraph.cloneNode(true) as HTMLElement; + plain.querySelector('[data-mention-kind="skill"]')!.replaceWith(element.textContent!); + paragraph.after(plain); + const plainHeight = plain.getBoundingClientRect().height; + plain.remove(); + return { height, plainHeight }; + }); + expect(heights.height).toBeCloseTo(heights.plainHeight, 1); + } + await page.evaluate(() => document.fonts.ready); + await page.screenshot({ path: testInfo.outputPath(`${surface}-${state}-${theme}-${mobile ? "mobile" : "desktop"}.png`), fullPage: true }); + if (state === "slash-commands") { + await page.setViewportSize({ width: mobile ? 430 : 1000, height: 844 }); + await page.locator(".paperclip-task-chat-composer").evaluate((element) => { + (element as HTMLElement).style.width = "85%"; + }); + await expect.poll(() => page.evaluate(() => { + const menu = document.querySelector('[data-testid="mention-autocomplete-menu"]')!.getBoundingClientRect(); + const composer = document.querySelector(".paperclip-task-chat-composer")!.getBoundingClientRect(); + return Math.abs(menu.width - composer.width) + Math.abs(menu.left - composer.left); + })).toBeLessThan(1); + } + }); + } + } + } +} + +for (const id of ["composer-skills--send-inline-skill", "composer-new-task--create-with-rich-chips"]) { + test(`${id} preserves submitted skill references`, async ({ page }) => { + await page.goto(`/iframe.html?id=${id}&viewMode=story`); + await expect.poll(() => page.evaluate(() => (window as unknown as { + __STORYBOOK_ADDONS_CHANNEL__?: { data: Record> }; + }).__STORYBOOK_ADDONS_CHANNEL__?.data.storyFinished?.[0]?.status), { timeout: 30_000 }).toBe("success"); + }); +} diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index bc1b176dbb1..7e07907c4d2 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -509,17 +509,34 @@ export function computeMentionMenuPosition( return { top, left, maxWidth }; } -function getMentionMenuSize(optionCount: number): MentionMenuSize { - const visibleRows = Math.max(1, Math.min(optionCount, 8)); +function getMentionMenuSize(options: AutocompleteOption[]): MentionMenuSize { + const rowHeight = Math.min(options.length, 8) * MENTION_MENU_ROW_HEIGHT; return { width: MENTION_MENU_WIDTH, height: Math.min( MENTION_MENU_HEIGHT, - visibleRows * MENTION_MENU_ROW_HEIGHT + MENTION_MENU_CHROME_HEIGHT, + Math.max(MENTION_MENU_ROW_HEIGHT, rowHeight) + MENTION_MENU_CHROME_HEIGHT, ), }; } +function computeSlashMenuPosition( + composer: DOMRect, + viewport: MentionMenuViewport, + menuSize: MentionMenuSize, +) { + const minLeft = viewport.offsetLeft + MENTION_MENU_PADDING; + const minTop = viewport.offsetTop + MENTION_MENU_PADDING; + const width = Math.min(composer.width, Math.max(0, viewport.width - MENTION_MENU_PADDING * 2)); + const maxLeft = viewport.offsetLeft + viewport.width - MENTION_MENU_PADDING - width; + const left = Math.max(minLeft, Math.min(viewport.offsetLeft + composer.left, maxLeft)); + const above = viewport.offsetTop + composer.top - menuSize.height - MENTION_MENU_PADDING; + const below = viewport.offsetTop + composer.bottom + MENTION_MENU_PADDING; + const maxTop = viewport.offsetTop + viewport.height - menuSize.height - MENTION_MENU_PADDING; + const top = Math.max(minTop, Math.min(above >= minTop ? above : below, maxTop)); + return { top, left, width, maxWidth: width }; +} + // CodeMirror's editable surface is `.cm-content`, not a `pre` or `code` node. // MDXEditor wraps that surface in a hashed `codeMirrorWrapper` class. A paste // that starts inside either of those belongs to the nested editor. @@ -1140,12 +1157,16 @@ export const MarkdownEditor = forwardRef viewport?.addEventListener("scroll", updatePosition); window.addEventListener("resize", updatePosition); window.addEventListener("scroll", updatePosition, true); + const anchor = containerRef.current?.closest("[data-editor-autocomplete-anchor]") ?? containerRef.current; + const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(updatePosition) : null; + if (anchor) observer?.observe(anchor); return () => { viewport?.removeEventListener("resize", updatePosition); viewport?.removeEventListener("scroll", updatePosition); window.removeEventListener("resize", updatePosition); window.removeEventListener("scroll", updatePosition, true); + observer?.disconnect(); }; }, [checkMention, mentionActive]); @@ -1335,12 +1356,11 @@ export const MarkdownEditor = forwardRef setRichEditorError({ code: "MDE-PARSE", message: richEditorErrorMessage(error) }); }, []); - const mentionMenuPosition = mentionState - ? computeMentionMenuPosition( - mentionState, - getMentionMenuViewport(), - getMentionMenuSize(filteredMentions.length), - ) + const autocompleteAnchor = containerRef.current?.closest("[data-editor-autocomplete-anchor]") ?? containerRef.current; + const mentionMenuPosition: { top: number; left: number; maxWidth: number; width?: number } | null = mentionState && autocompleteAnchor + ? mentionState.trigger === "skill" + ? computeSlashMenuPosition(autocompleteAnchor.getBoundingClientRect(), getMentionMenuViewport(), getMentionMenuSize(filteredMentions)) + : computeMentionMenuPosition(mentionState, getMentionMenuViewport(), getMentionMenuSize(filteredMentions)) : null; if (richEditorError) { @@ -1623,8 +1643,12 @@ export const MarkdownEditor = forwardRef
) : option.kind === "action" ? ( ) : option.kind === "skill" ? ( - + ) : option.kind === "issue" ? ( ) : option.kind === "project" && option.projectId ? ( @@ -1700,9 +1724,18 @@ export const MarkdownEditor = forwardRef {issueMentionTitle(option)} + ) : option.kind === "skill" ? ( + + {slashCommandLabel(option)} + {option.description && ( + + {option.description} + + )} + ) : ( - {option.kind === "skill" || option.kind === "routine" || option.kind === "action" + {option.kind === "routine" || option.kind === "action" ? slashCommandLabel(option) : option.name} @@ -1723,7 +1756,7 @@ export const MarkdownEditor = forwardRef )} {option.kind === "skill" && ( - + Skill )} @@ -1733,7 +1766,7 @@ export const MarkdownEditor = forwardRef )} {option.kind === "action" && ( - + {option.disabled ? option.disabledReason : option.description} )} diff --git a/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx b/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx index a4c64a86ee5..b25a2370a3c 100644 --- a/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx +++ b/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx @@ -209,7 +209,7 @@ export function ComposerRunSettingsPicker({ const Trigger = mobile ? DialogTrigger : PopoverTrigger; const modelTrigger = ; diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index df9ee2ea889..5b81f86bd99 100644 --- a/ui/src/components/task-chat/TaskChatComposer.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.tsx @@ -1328,6 +1328,7 @@ export function TaskChatComposer({ ) : null}
{ // Capture mode shortcuts on the wrapper so they work while the rich diff --git a/ui/src/index.css b/ui/src/index.css index accb243f7c0..83bcbe9b2a1 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -642,6 +642,9 @@ --primary-crown-bubble-size: 7px; --primary-crown-bubble-travel: 30px; --motion-ease-primary-crown-burst: cubic-bezier(0.2, 0.8, 0.3, 1); + --skill-mention-icon-offset: -0.125em; + --skill-mention-foreground: var(--color-violet-700); + --skill-mention-icon: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M2.97%2012.92A2%202%200%200%200%202%2014.63v3.24a2%202%200%200%200%20.97%201.71l3%201.8a2%202%200%200%200%202.06%200L12%2019v-5.5l-5-3-4.03%202.42Z%22%2F%3E%3Cpath%20d%3D%22m7%2016.5-4.74-2.85%22%2F%3E%3Cpath%20d%3D%22m7%2016.5%205-3%22%2F%3E%3Cpath%20d%3D%22M7%2016.5v5.17%22%2F%3E%3Cpath%20d%3D%22M12%2013.5V19l3.97%202.38a2%202%200%200%200%202.06%200l3-1.8a2%202%200%200%200%20.97-1.71v-3.24a2%202%200%200%200-.97-1.71L17%2010.5l-5%203Z%22%2F%3E%3Cpath%20d%3D%22m17%2016.5-5-3%22%2F%3E%3Cpath%20d%3D%22m17%2016.5%204.74-2.85%22%2F%3E%3Cpath%20d%3D%22M17%2016.5v5.17%22%2F%3E%3Cpath%20d%3D%22M7.97%204.42A2%202%200%200%200%207%206.13v4.37l5%203%205-3V6.13a2%202%200%200%200-.97-1.71l-3-1.8a2%202%200%200%200-2.06%200l-3%201.8Z%22%2F%3E%3Cpath%20d%3D%22M12%208%207.26%205.15%22%2F%3E%3Cpath%20d%3D%22m12%208%204.74-2.85%22%2F%3E%3Cpath%20d%3D%22M12%2013.5V8%22%2F%3E%3C%2Fsvg%3E"); } .dark { @@ -719,6 +722,7 @@ --patch-remove-fg: #ffa198; --status-task-icon-done: #34d06f; --status-task-icon-cancelled: #9a958a; + --skill-mention-foreground: var(--color-violet-300); } .streamlined-task-detail-surface { @@ -2201,6 +2205,32 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { background-size: contain; } +.paperclip-mdxeditor-content a.paperclip-mention-chip[data-mention-kind="skill"] { + display: inline; + min-height: 0; + margin: 0; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: var(--skill-mention-foreground); + font-size: inherit; + line-height: inherit; + vertical-align: baseline; + top: auto; +} + +.paperclip-mdxeditor-content a.paperclip-mention-chip[data-mention-kind="skill"]::before { + display: inline-block; + width: 1em; + height: 1em; + margin-inline-end: var(--spacing); + vertical-align: var(--skill-mention-icon-offset); + background-color: currentColor; + -webkit-mask: var(--skill-mention-icon) center / contain no-repeat; + mask: var(--skill-mention-icon) center / contain no-repeat; +} + .paperclip-mdxeditor-content ul, .paperclip-mdxeditor-content ol { margin: 1.1em 0; diff --git a/ui/storybook/stories/tasks/composer-skills.stories.tsx b/ui/storybook/stories/tasks/composer-skills.stories.tsx new file mode 100644 index 00000000000..29ee68a907a --- /dev/null +++ b/ui/storybook/stories/tasks/composer-skills.stories.tsx @@ -0,0 +1,106 @@ +import { useLayoutEffect, useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import { useQueryClient } from "@tanstack/react-query"; +import { buildSkillMentionHref } from "@paperclipai/shared"; +import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; +import { TaskChatComposerDock } from "@/components/task-chat/TaskChatComposerDock"; +import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView"; +import { useCompany } from "@/context/CompanyContext"; +import { useSidebar } from "@/context/SidebarContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { composerSkills } from "../../support/composerSkills"; + +function TaskSkillsStory() { + const client = useQueryClient(); + const { selectedCompanyId } = useCompany(); + const { isMobile } = useSidebar(); + const [sent, setSent] = useState(null); + + useLayoutEffect(() => { + if (!selectedCompanyId) return; + const originalFetch = window.fetch; + window.fetch = async (input, init) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, location.origin); + if (url.pathname === `/api/companies/${selectedCompanyId}/skills`) return Response.json(composerSkills); + if (url.pathname === `/api/companies/${selectedCompanyId}/routines`) return Response.json([]); + return originalFetch(input, init); + }; + client.setQueryData(queryKeys.companySkills.list(selectedCompanyId), composerSkills); + client.setQueryData(queryKeys.routines.list(selectedCompanyId), []); + return () => { window.fetch = originalFetch; }; + }, [client, selectedCompanyId]); + + return
+
Tasks / Fix the sign-in redirect
+
+

Fix the sign-in redirect

+
+ + {sent && {sent}} + + { setSent(body); }} /> + +
+
+
; +} + +const meta = { + id: "composer-skills", + title: "Tasks and conversations/Task creation and lifecycle/Composer/Skills", + component: TaskSkillsStory, + parameters: { + layout: "fullscreen", + paperclip: { + capabilities: ["tasks/discussion", "tasks/create"], + status: "current", + implementation: "production", + boundary: "fixtures", + notes: "Production task thread, dock, composer, and shared editor. Covers skill descriptions, absent descriptions, keyboard insertion, inline color/icon styling, and Markdown submission on desktop and mobile. Skills and submission use fixtures; no task persistence or agent execution is exercised. New-task equivalents are in new-task-composer.stories.tsx.", + }, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const SlashCommands: Story = { + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.type(await page.findByRole("textbox", { name: "editable markdown" }), "/", { delay: 25 }); + const menu = within(await page.findByTestId("mention-autocomplete-menu")); + await expect(menu.getByText("/test-it-for-real")).toBeVisible(); + await expect(menu.getByText(composerSkills[0]!.description!)).toBeVisible(); + await expect(menu.getByText(`/${composerSkills[2]!.slug}`)).toBeVisible(); + }, +}; + +export const InlineSkill: Story = { + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const editor = await page.findByRole("textbox", { name: "editable markdown" }); + await userEvent.type(editor, "Use /test-it", { delay: 25 }); + await expect(await page.findByTestId("mention-autocomplete-menu")).toBeVisible(); + await userEvent.keyboard("{Tab}"); + await waitFor(() => expect(editor.querySelector('[data-mention-kind="skill"]')).toBeVisible()); + await userEvent.keyboard(" to verify the sign-in redirect.", { delay: 25 }); + await expect(editor).toHaveTextContent("Use /test-it-for-real to verify the sign-in redirect."); + }, +}; + +export const SendInlineSkill: Story = { + play: async (context) => { + await InlineSkill.play!(context); + const page = within(context.canvasElement.ownerDocument.body); + await userEvent.keyboard("{Enter}"); + await expect(await page.findByRole("status", { name: "Submitted Markdown" })).toHaveTextContent( + `Use [/test-it-for-real](${buildSkillMentionHref(composerSkills[0]!.id, composerSkills[0]!.slug)}) to verify the sign-in redirect.`, + ); + }, +}; + +export const MobileSlashCommands: Story = { ...SlashCommands, globals: { viewport: { value: "mobile", isRotated: false } } }; +export const MobileInlineSkill: Story = { ...InlineSkill, globals: { viewport: { value: "mobile", isRotated: false } } }; diff --git a/ui/storybook/stories/tasks/new-task-composer.stories.tsx b/ui/storybook/stories/tasks/new-task-composer.stories.tsx index 816f88fc15c..2b31027fdda 100644 --- a/ui/storybook/stories/tasks/new-task-composer.stories.tsx +++ b/ui/storybook/stories/tasks/new-task-composer.stories.tsx @@ -13,6 +13,7 @@ import { } from "@paperclipai/shared"; import { MarkdownBody } from "@/components/MarkdownBody"; import { composerImageFiles } from "../../support/composerImageFiles"; +import { composerSkills as SKILLS } from "../../support/composerSkills"; import { NewIssueDialog } from "@/components/NewIssueDialog"; import { useCompany } from "@/context/CompanyContext"; import { useDialog } from "@/context/DialogContext"; @@ -35,7 +36,6 @@ const NEW_TASK_AGENTS = [...storybookAgents, { ...storybookAgents[2]!, id: "agen : { ...agent, appearance: appearanceForPalette("pink-lemonade") }, ); const REQUEST = "Review the sign-in flow and fix the redirect after a session expires."; -const SKILLS = [{ id: "skill-test-drive", key: "test-it-for-real", slug: "test-it-for-real", name: "Test it for real", description: "Walk through the feature in the browser." }]; const ROUTINES = [{ id: "routine-daily-check", title: "Daily check-in", status: "active" }]; const RICH_REQUEST = [ `Ask [@CodexCoder](${buildAgentMentionHref("agent-codex", "code")}) and [@QAChecker](${buildAgentMentionHref("agent-qa", "shield")}) to review [@Board UI](${buildProjectMentionHref("project-board-ui", storybookProjects[0]!.color)}).`, @@ -481,10 +481,13 @@ export const MentionPicker: Story = { export const SlashCommands: Story = { play: async ({ canvasElement }) => { const page = within(canvasElement.ownerDocument.body); - await userEvent.type(await page.findByRole("textbox", { name: "editable markdown" }), "/"); + await waitFor(() => expect(page.getByTestId("task-chat-composer-assignee-label")).toHaveTextContent("CEO")); + await userEvent.type(await page.findByRole("textbox", { name: "editable markdown" }), "/", { delay: 25 }); const menuElement = await page.findByTestId("mention-autocomplete-menu"); const menu = within(menuElement); await expect(menu.getByText("/test-it-for-real")).toBeVisible(); + await expect(menu.getByText(SKILLS[0]!.description!)).toBeVisible(); + await expect(menu.getByText(`/${SKILLS[2]!.slug}`)).toBeVisible(); await expect(menu.getByText("/routine:Daily check-in")).toBeVisible(); await expect(menuElement.getBoundingClientRect().right).toBeLessThanOrEqual(canvasElement.ownerDocument.defaultView!.innerWidth); }, @@ -513,6 +516,7 @@ export const CreateWithRichChips: Story = { const receipt = await page.findByRole("status"); await expect(receipt).toHaveTextContent("Task created"); await expect(receipt).toHaveTextContent("/test-it-for-real"); + await expect(within(receipt).getByRole("link", { name: "/test-it-for-real" })).toHaveAttribute("href", `/skills/${SKILLS[0]!.id}`); await expect(receipt).toHaveTextContent("/routine:Daily check-in"); await expect(receipt.querySelector('[data-mention-kind="agent"]')).toHaveAttribute("style", expect.stringContaining("electric-grove")); }, @@ -553,6 +557,21 @@ export const MobileSlashCommands: Story = { ...SlashCommands, globals: { viewpor export const MobileRichChips: Story = { ...RichChips, globals: { viewport: { value: "mobile", isRotated: false } } }; export const MobileInsertRichChips: Story = { ...InsertRichChips, globals: { viewport: { value: "mobile", isRotated: false } } }; +export const InlineSkill: Story = { + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await waitFor(() => expect(page.getByTestId("task-chat-composer-assignee-label")).toHaveTextContent("CEO")); + const editor = await page.findByRole("textbox", { name: "editable markdown" }); + await userEvent.type(editor, "Use /test-it", { delay: 25 }); + await expect(await page.findByTestId("mention-autocomplete-menu")).toBeVisible(); + await userEvent.keyboard("{Tab}"); + await waitFor(() => expect(editor.querySelector('[data-mention-kind="skill"]')).toBeVisible()); + await userEvent.keyboard(" to verify the sign-in redirect.", { delay: 25 }); + await expect(editor).toHaveTextContent("Use /test-it-for-real to verify the sign-in redirect."); + }, +}; +export const MobileInlineSkill: Story = { ...InlineSkill, globals: { viewport: { value: "mobile", isRotated: false } } }; + export const ScreenDragOver: Story = { play: async ({ canvasElement }) => { const document = canvasElement.ownerDocument; diff --git a/ui/storybook/support/composerSkills.ts b/ui/storybook/support/composerSkills.ts new file mode 100644 index 00000000000..aa6f751219b --- /dev/null +++ b/ui/storybook/support/composerSkills.ts @@ -0,0 +1,23 @@ +export const composerSkills = [ + { + id: "skill-test-drive", + key: "test-it-for-real", + slug: "test-it-for-real", + name: "Test it for real", + description: "Walk through the feature in the browser, check the complete user journey, and capture screenshots of the result for review.", + }, + { + id: "skill-check-pr", + key: "check-pr", + slug: "check-pr", + name: "Check PR", + description: "Review pull request comments and failing checks before submission.", + }, + { + id: "skill-release-notes", + key: "release-notes-for-project", + slug: "release-notes-for-project", + name: "Release notes", + description: null, + }, +];