diff --git a/packages/app/e2e/session/titlebar-right-rail-contract.spec.ts b/packages/app/e2e/session/titlebar-right-rail-contract.spec.ts index f1a8ade93..eb6e85386 100644 --- a/packages/app/e2e/session/titlebar-right-rail-contract.spec.ts +++ b/packages/app/e2e/session/titlebar-right-rail-contract.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "../fixtures" -import { openRightPanel } from "../actions" +import { openRightPanel, openSettings, openSidebar } from "../actions" import { modKey } from "../utils" // Contract for the titlebar's right rail — the flex row inside the titlebar's @@ -142,6 +142,57 @@ test.describe("titlebar right rail contract", () => { .toEqual({ width: 0, borderLeft: "0px" }) }) + test("a main-takeover surface hides the right-rail chrome (border-l, reserved width, portalled tabs)", async ({ + page, + gotoSession, + }) => { + // Regression: settings / automations / skills are main-region takeovers that + // cover the (still-mounted) session and its right panel. The rail's width + + // border-l and the tab-strip portal are the right panel's chrome inside the + // titlebar; when a surface covers the panel, that chrome must retract too. + // Before the mainSurfaceOpen() guard, automations/skills left an empty rail + // reserving panel-width with a 1px border-l, and settings additionally left + // the portalled tabs ("状态") mounted. One assertion covers all three. + await gotoSession() + await openRightPanel(page) + await openSidebar(page) + + const railState = () => + page.evaluate(() => { + const tabs = document.getElementById("pawwork-titlebar-tabs") as HTMLElement | null + const cs = tabs ? getComputedStyle(tabs) : null + return { + width: tabs ? Math.round(tabs.getBoundingClientRect().width) : null, + borderLeft: cs?.borderLeftWidth ?? null, + children: tabs ? tabs.childElementCount : null, + } + }) + + // Sanity: panel open, no surface → rail reserves width with its border-l. + expect((await railState()).width).toBeGreaterThan(0) + + const expectRetracted = async () => + expect.poll(railState, { timeout: 2_000 }).toEqual({ width: 0, borderLeft: "0px", children: 0 }) + + // Automations (sidebar stays live, so the entry is clickable to toggle). + await page.locator('[data-action="pawwork-automations-open"]').click() + await page.locator('[data-component="automations-page"]').waitFor({ state: "visible", timeout: 30_000 }) + await expectRetracted() + await page.locator('[data-action="pawwork-automations-open"]').click() + + // Skills. + await page.locator('[data-action="pawwork-skills-open"]').click() + await page.locator('[data-component="skills-page"]').waitFor({ state: "visible", timeout: 30_000 }) + await expectRetracted() + await page.locator('[data-action="pawwork-skills-open"]').click() + + // Settings (replaces the sidebar; open it last). This is the leg the empty + // automations/skills gate missed — the tab-strip portal was not gated here. + await openSettings(page) + await page.locator('[data-component="settings-page"]').first().waitFor({ state: "visible", timeout: 30_000 }) + await expectRetracted() + }) + test("tabs slot border-l spans the full titlebar height (no top/bottom seam break)", async ({ page, gotoSession, diff --git a/packages/app/e2e/skills/skills-panel.spec.ts b/packages/app/e2e/skills/skills-panel.spec.ts new file mode 100644 index 000000000..e53ad4333 --- /dev/null +++ b/packages/app/e2e/skills/skills-panel.spec.ts @@ -0,0 +1,121 @@ +import { mkdir, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { test, expect } from "../fixtures" +import { openSidebar } from "../actions" +import { promptSelector } from "../selectors" + +// Seed a project-scoped skill so the gallery has a deterministic capability to +// open and activate in a clean env, independent of any ambient global skills. +async function seedProjectSkill(directory: string, name: string, description: string) { + const skillDir = join(directory, ".agents", "skills", name) + await mkdir(skillDir, { recursive: true }) + await writeFile( + join(skillDir, "SKILL.md"), + ["---", `name: ${name}`, `description: ${description}`, "---", "", "Summarize the thread into three bullets."].join("\n"), + ) +} + +test("Skills sidebar entry opens the gallery; Escape closes detail then surface", async ({ page, project }) => { + await project.open({ + setup: async (directory) => { + await seedProjectSkill(directory, "summarize", "Summarize the conversation") + }, + }) + await openSidebar(page) + + await page.locator('[data-action="pawwork-skills-open"]').click() + const surface = page.locator('[data-component="skills-page"]') + await expect(surface).toBeVisible() + + const row = surface.locator('[data-action="skill-open"][data-skill="summarize"]') + await expect(row).toBeVisible({ timeout: 30_000 }) + await row.click() + + const detail = page.locator('[data-component="skill-detail"]') + await expect(detail).toBeVisible() + + // Escape returns to the gallery first, then closes the surface. + await page.keyboard.press("Escape") + await expect(detail).toHaveCount(0) + await expect(surface).toBeVisible() + + // The surface's Escape defers to the dialog stack, which clears `active` + // ~100ms after the detail unmounts (exit grace in useDialog). Wait it out — as + // a real user does, watching the modal vanish — before the second Escape. + await page.waitForTimeout(200) + await page.keyboard.press("Escape") + await expect(surface).toHaveCount(0) +}) + +test("Escape over the gallery closes a sidebar-opened palette first, then the surface", async ({ page, project }) => { + await project.open({ + setup: async (directory) => { + await seedProjectSkill(directory, "summarize", "Summarize the conversation") + }, + }) + await openSidebar(page) + + await page.locator('[data-action="pawwork-skills-open"]').click() + const surface = page.locator('[data-component="skills-page"]') + await expect(surface).toBeVisible() + + // The sidebar stays live behind the gallery; its search opens the command + // palette via command.show(), which bypasses the keybind gate that otherwise + // suppresses palettes behind a surface. The gallery's Escape handler must + // defer to that palette (a dialog-stack modal) before closing itself. + await page.locator('[data-action="pawwork-session-search"]').click() + const palette = page.getByRole("dialog") + await expect(palette).toBeVisible() + + await page.keyboard.press("Escape") + await expect(palette).toHaveCount(0) + await expect(surface).toBeVisible() + + await page.waitForTimeout(200) + await page.keyboard.press("Escape") + await expect(surface).toHaveCount(0) +}) + +test("a deep link carrying both ?skill= and ?prompt= seeds only the skill chip", async ({ page, project }) => { + await project.open({ + setup: async (directory) => { + await seedProjectSkill(directory, "summarize", "Summarize the conversation") + }, + }) + + // Skill wins on a combined link: the chip seeds and the stray prompt text must + // not survive (the skill bootstrap clears both params so prompt can't re-seed). + await page.goto(`/${project.slug}/session?skill=summarize&prompt=hello`) + + await expect(page.locator(promptSelector)).toBeVisible() + const chip = page.locator('[data-type="skill"][data-name="summarize"]') + await expect(chip).toBeVisible({ timeout: 30_000 }) + await expect(chip.locator("[data-cmd-label]")).toHaveText("summarize") + await expect(page.locator(promptSelector)).not.toContainText("hello") +}) + +test("Use in chat opens a new session and inserts the skill chip", async ({ page, project }) => { + await project.open({ + setup: async (directory) => { + await seedProjectSkill(directory, "summarize", "Summarize the conversation") + }, + }) + await openSidebar(page) + + await page.locator('[data-action="pawwork-skills-open"]').click() + const surface = page.locator('[data-component="skills-page"]') + await expect(surface).toBeVisible() + + await surface.locator('[data-action="skill-open"][data-skill="summarize"]').click() + await expect(page.locator('[data-component="skill-detail"]')).toBeVisible() + + await page.locator('[data-action="skill-use-in-chat"]').click() + + // The surface closes and a fresh session composer seeds the structured skill + // chip, exactly as typing /summarize would, with no leading slash on the label. + await expect(surface).toHaveCount(0) + await expect(page.locator(promptSelector)).toBeVisible() + const chip = page.locator('[data-type="skill"][data-name="summarize"]') + await expect(chip).toBeVisible({ timeout: 30_000 }) + await expect(chip.locator("[data-cmd-label]")).toHaveText("summarize") +}) diff --git a/packages/app/e2e/snap/skills-surface.snap.ts b/packages/app/e2e/snap/skills-surface.snap.ts new file mode 100644 index 000000000..920c496f6 --- /dev/null +++ b/packages/app/e2e/snap/skills-surface.snap.ts @@ -0,0 +1,107 @@ +import { mkdir, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { test, expect } from "../fixtures" +import { openSidebar } from "../actions" +import { composeGrid, snapOutputPath, type Shot } from "./_compose" + +test.use({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 }) + +// Seed project-scoped skills (.agents/skills//SKILL.md) so the gallery has +// real installed capabilities to render in a clean env. Descriptions mirror the +// long machine-facing trigger text the real officecli/morph skills carry, since +// reading well against that text is the design risk the gallery has to clear. +type Seed = { name: string; description: string; body: string } + +const SEEDS: Seed[] = [ + { + name: "officecli-docx", + description: + "Use this skill any time a .docx Word document needs to be created, edited, or inspected. Trigger on requests to draft letters, reports, contracts, or any formatted prose that should ship as Word.", + body: ["## Overview", "", "Build and edit Word documents from the command line.", "", "```bash", "officecli docx build report.md --out report.docx", "```"].join("\n"), + }, + { + name: "officecli-xlsx", + description: + "Use this skill when a spreadsheet is involved: creating, reading, or modifying .xlsx files, computing tables, or turning data into a workbook the user can open in Excel.", + body: ["## Overview", "", "Generate and edit spreadsheets.", "", "```bash", "officecli xlsx build data.csv --out book.xlsx", "```"].join("\n"), + }, + { + name: "officecli-pptx", + description: + "Use this skill to produce slide decks. Trigger on any request to build, edit, or restructure a .pptx PowerPoint presentation from an outline or notes.", + body: ["## Overview", "", "Assemble slide decks from an outline.", "", "```bash", "officecli pptx build outline.md --out deck.pptx", "```"].join("\n"), + }, + { + name: "morph-apply", + description: + "Use this skill to apply a fast, surgical edit to a single file given a patch description, when a full rewrite would be wasteful and the change is localized.", + body: ["## Overview", "", "Apply a localized edit without rewriting the whole file."].join("\n"), + }, + { + name: "pdf-extract", + description: + "Use this skill when text or tables need to be pulled out of a PDF: invoices, scanned reports, forms, or any document delivered as .pdf.", + body: ["## Overview", "", "Extract text and tables from PDF files."].join("\n"), + }, + { + name: "web-research", + description: + "Use this skill to gather current information from the web: news, prices, documentation, or any fact that may have changed since training.", + body: ["## Overview", "", "Search the web and summarize findings with sources."].join("\n"), + }, +] + +async function seedSkill(directory: string, seed: Seed) { + const skillDir = join(directory, ".agents", "skills", seed.name) + await mkdir(skillDir, { recursive: true }) + await writeFile( + join(skillDir, "SKILL.md"), + ["---", `name: ${seed.name}`, `description: ${seed.description}`, "---", "", seed.body, ""].join("\n"), + ) +} + +test("skills-surface", async ({ page, project }) => { + test.setTimeout(180_000) + + await project.open({ + setup: async (directory) => { + for (const seed of SEEDS) await seedSkill(directory, seed) + }, + }) + await openSidebar(page) + + await page.locator('[data-action="pawwork-skills-open"]').click() + const surface = page.locator('[data-component="skills-page"]') + await surface.waitFor({ state: "visible", timeout: 30_000 }) + + const rows = surface.locator('[data-action="skill-open"]') + await rows.first().waitFor({ state: "visible", timeout: 30_000 }) + await expect.poll(() => rows.count()).toBeGreaterThanOrEqual(6) + const gallery = await page.screenshot() + + // Open one capability to read its detail modal: humanized title, verbatim + // description, and the full SKILL.md markdown body with a copyable code block. + await surface.locator('[data-action="skill-open"][data-skill="officecli-docx"]').click() + const detail = page.locator('[data-component="skill-detail"]') + await detail.waitFor({ state: "visible", timeout: 30_000 }) + const detailShot = await page.screenshot() + + // Close and filter: the search box narrows the grid to matching capabilities. + // "xlsx" keeps the spreadsheet skill (and any whose description mentions it) + // and drops the unrelated ones, e.g. the seeded web-research capability. + await page.locator('[data-slot="dialog-close-button"]').click() + await detail.waitFor({ state: "detached", timeout: 10_000 }) + await surface.locator('[data-action="skill-search"]').fill("xlsx") + await surface.locator('[data-action="skill-open"][data-skill="officecli-xlsx"]').waitFor({ state: "visible", timeout: 10_000 }) + await expect(page.locator('[data-action="skill-open"][data-skill="web-research"]')).toHaveCount(0) + const filtered = await page.screenshot() + + const shots: Shot[] = [ + { name: "gallery", buf: gallery }, + { name: "detail", buf: detailShot }, + { name: "search", buf: filtered }, + ] + const out = snapOutputPath("skills-surface") + await composeGrid(shots, out) + process.stdout.write(`\n[snap] skills-surface grid -> ${out}\n\n`) +}) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 0f7655e46..b4d58f18d 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -79,7 +79,7 @@ export function SessionHeader() { return ( <> - + {(mount) => ( diff --git a/packages/app/src/pages/layout/pawwork-sidebar-top.tsx b/packages/app/src/pages/layout/pawwork-sidebar-top.tsx index 7eb977c20..9bf966fc7 100644 --- a/packages/app/src/pages/layout/pawwork-sidebar-top.tsx +++ b/packages/app/src/pages/layout/pawwork-sidebar-top.tsx @@ -6,10 +6,13 @@ import { useLanguage } from "@/context/language" export function PawworkSidebarTop(props: { newSessionKeybind: Accessor searchKeybind: Accessor + skillsActive: Accessor + skillsLabel: Accessor automationsActive: Accessor automationsLabel: Accessor onNew: () => void onSearch: () => void + onOpenSkills: () => void onOpenAutomations: () => void }) { const language = useLanguage() @@ -50,6 +53,26 @@ export function PawworkSidebarTop(props: { {language.t("sidebar.pawwork.search")} + + + + ) +} + +export function SkillsSurface(props: { + directory: Accessor + onClose: () => void + onUseSkill: (name: string) => void +}): JSX.Element { + const globalSDK = useGlobalSDK() + const language = useLanguage() + const dialog = useDialog() + const [query, setQuery] = createSignal("") + + // The detail reader is a modal in the shared dialog stack; opening it there + // gets focus trap / initial focus / focus restore / background inert for free + // instead of re-deriving them on a hand-rolled overlay. "Use in chat" closes + // the dialog before navigating so the stack unwinds cleanly. + const openDetail = (skill: SkillInfo) => { + dialog.show(() => ( + { + dialog.close() + props.onUseSkill(skill.name) + }} + > + {language.t("skills.detail.useInChat")} + + } + /> + )) + } + + const [skills] = createResource( + () => props.directory(), + async (directory) => { + const res = await globalSDK.client.app.skills({ directory }) + return (res.data ?? []).slice().sort((a, b) => skillTitle(a.name).localeCompare(skillTitle(b.name))) + }, + ) + + const filtered = createMemo(() => { + const all = skills() ?? [] + const needle = query() + return needle ? all.filter((skill) => skillMatches(skill, needle)) : all + }) + + // While the resource is still resolving its first batch, `skills()` is + // undefined and `filtered()` is empty — rendering the empty-state copy then + // would flash "no skills" on every normal load and mislabel a load failure as + // "no skills". Key the body off the resource state so the empty copy only + // shows once we actually have a (filtered-to-zero) result, and load failures + // get their own message. + const view = createMemo<"loading" | "error" | "empty" | "list">(() => { + if (skills.state === "errored") return "error" + if (skills.state === "pending" || skills.state === "unresolved") return "loading" + return filtered().length > 0 ? "list" : "empty" + }) + + // Escape closes the surface — but only when nothing in the shared dialog stack + // owns it. That covers the skill-detail reader AND a command palette opened + // from the still-live sidebar (its search calls command.show() directly, + // bypassing the keybind gate that otherwise suppresses palettes behind a + // surface). `dialog.active` is the single source of truth for "a modal owns + // Escape", so we defer to it instead of sniffing the DOM for overlay + // components; we bail without preventing default so the event still reaches + // whatever modal is up. Only when the stack is empty do we close the surface. + onMount(() => { + const onEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return + if (dialog.active) return + event.preventDefault() + props.onClose() + } + document.addEventListener("keydown", onEscape, true) + onCleanup(() => document.removeEventListener("keydown", onEscape, true)) + }) + + return ( +
+
+
+
+

{language.t("skills.title")}

+

{language.t("skills.subtitle")}

+
+ +
+ + + {/* Reserve the row band's height so the layout doesn't jump when the + list lands; no "Loading…" copy (local skills resolve fast, and the + label would just flicker). */} + + +
+ ) +}