From 790e79586a0bdefbbb0779da7b161c226ef46bfd Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 5 Jun 2026 12:04:30 +0800 Subject: [PATCH 01/13] feat(app): add read-only skills capability gallery Add a top-level "Skills" surface that shows what the agent can do, sitting between Search and Automations in the PawWork sidebar. The dominant unmet need for a non-technical user is discovery ("what can this thing do"), not toggling, so v1 is a read-only capability gallery; management and a marketplace are deferred layers. Rendering is generic and format-driven, with no per-skill curation: the skill name is humanized into a readable title and the description is shown verbatim. skillSummary() is the single isolated seam where a future schema-free summary derivation can land without touching skills or the data model. The gallery reads the real installed skills from the existing GET /skill route (client.app.skills) and renders grouped two-column borderless icon rows; a search box filters across title, name, and description. Opening a row shows a minimal detail modal: brand glyph, title, verbatim description, and the full SKILL.md body via the shared @opencode-ai/ui/markdown renderer. The surface mirrors the Automations shell-takeover wiring (activeSurface signal, LayoutShellFrame slot, sidebar entry, ShellSurface context) and keeps the session sidebar live behind it. Escape closes the open detail first, then the surface. Visual check: added the skills-surface snap target (real backend skills, seeded project skills) and reviewed the gallery, detail modal, and filtered grid in docs/design/preview/screenshots/skills-surface.png. Design direction recorded on #220. --- packages/app/e2e/snap/skills-surface.snap.ts | 108 ++++++++++++++++ packages/app/src/context/shell-surface.tsx | 3 + packages/app/src/i18n/en.ts | 6 + packages/app/src/i18n/zh.ts | 6 + packages/app/src/pages/layout.tsx | 24 +++- .../src/pages/layout/layout-shell-frame.tsx | 18 ++- .../src/pages/layout/pawwork-sidebar-top.tsx | 23 ++++ .../app/src/pages/layout/pawwork-sidebar.tsx | 6 + .../app/src/pages/skills/skill-detail.tsx | 69 ++++++++++ .../src/pages/skills/skill-presentation.ts | 37 ++++++ .../app/src/pages/skills/skills-surface.tsx | 118 ++++++++++++++++++ 11 files changed, 415 insertions(+), 3 deletions(-) create mode 100644 packages/app/e2e/snap/skills-surface.snap.ts create mode 100644 packages/app/src/pages/skills/skill-detail.tsx create mode 100644 packages/app/src/pages/skills/skill-presentation.ts create mode 100644 packages/app/src/pages/skills/skills-surface.tsx 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..bb1e793de --- /dev/null +++ b/packages/app/e2e/snap/skills-surface.snap.ts @@ -0,0 +1,108 @@ +import { mkdir, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { test } 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 page.waitForFunction(() => document.querySelectorAll('[data-action="skill-open"]').length >= 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-action="skill-detail-close"]').click() + 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 page.waitForFunction( + () => document.querySelectorAll('[data-action="skill-open"][data-skill="web-research"]').length === 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/context/shell-surface.tsx b/packages/app/src/context/shell-surface.tsx index af8b870da..e3a103384 100644 --- a/packages/app/src/context/shell-surface.tsx +++ b/packages/app/src/context/shell-surface.tsx @@ -5,10 +5,13 @@ import type { SettingsTab } from "../pages/settings/settings-shell" export type ShellSurfaceContextValue = { settingsOpen: Accessor automationsOpen: Accessor + skillsOpen: Accessor openNewSession: (directory?: string) => void openSession: (session: Session | undefined) => void openSettings: (tab?: SettingsTab) => void closeSettings: () => void + openSkills: () => void + closeSkills: () => void } export const ShellSurfaceContext = createContext() diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 738d11cd6..d7003e7f9 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -794,7 +794,13 @@ export const dict = { "sidebar.empty.title": "No projects open", "sidebar.empty.description": "Open a project to get started", "sidebar.pawwork.search": "Search", + "sidebar.pawwork.skills": "Skills", "sidebar.pawwork.automations": "Automations", + "skills.title": "Skills", + "skills.subtitle": "What PawWork can do. Open one to see when it fires and how.", + "skills.search.placeholder": "Search skills", + "skills.empty.title": "No skills match your search", + "skills.detail.suffix": "Skill", "automations.title": "Automations", "automations.empty.title": "No automations yet", "automations.empty.description": "Ask the assistant in a chat to automate a task for this project.", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 7faaa4230..eb0a8f6ed 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -704,7 +704,13 @@ export const dict = { "sidebar.empty.title": "还没有打开项目", "sidebar.empty.description": "打开一个项目开始使用", "sidebar.pawwork.search": "搜索", + "sidebar.pawwork.skills": "技能", "sidebar.pawwork.automations": "定时任务", + "skills.title": "技能", + "skills.subtitle": "PawWork 能做的事。点开任意一项看它何时触发、如何工作。", + "skills.search.placeholder": "搜索技能", + "skills.empty.title": "没有匹配的技能", + "skills.detail.suffix": "技能", "automations.title": "定时任务", "automations.empty.title": "还没有定时任务", "automations.empty.description": "在对话里让助手把当前项目的某个任务设为自动运行。", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index a8044cb70..5c016caca 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -73,6 +73,7 @@ import { createPawworkWorkspaceDialogs } from "./layout/pawwork-workspace-dialog import { type WorkspaceSidebarContext } from "./layout/sidebar-workspace" import { PawworkSidebar, type PawworkSidebarSession } from "./layout/pawwork-sidebar" import { AutomationsSurface } from "@/pages/automations/automations-surface" +import { SkillsSurface } from "@/pages/skills/skills-surface" import { createDefaultLayoutPageState, createLayoutPagePersistTarget } from "./layout/layout-page-store" import { SettingsContent, SettingsNav, isSettingsTab, type SettingsTab } from "@/pages/settings/settings-shell" import { DialogDeleteSession } from "@/components/dialog-delete-session" @@ -93,9 +94,10 @@ export default function Layout(props: ParentProps) { let dialogDead = false // One mutually-exclusive shell surface at a time. Settings replaces the // sidebar + main; automations only takes over main (sidebar stays live). - const [activeSurface, setActiveSurface] = createSignal<"none" | "settings" | "automations">("none") + const [activeSurface, setActiveSurface] = createSignal<"none" | "settings" | "automations" | "skills">("none") const settingsOpen = createMemo(() => activeSurface() === "settings") const automationsOpen = createMemo(() => activeSurface() === "automations") + const skillsOpen = createMemo(() => activeSurface() === "skills") // Pending deep-link selection for the Automations panel; set just before the // surface opens (e.g. the automate tool's jump button) and read once on its // mount. Cleared on manual opens so a stale id never forces a row. @@ -489,6 +491,10 @@ export default function Layout(props: ParentProps) { setActiveSurface((current) => (current === "automations" ? "none" : "automations")) } + function toggleSkills() { + setActiveSurface((current) => (current === "skills" ? "none" : "skills")) + } + // Open the Automations panel focused on one automation. Wired to the // module-level bridge so the automate tool card (deep in the message thread, // outside this shell) can jump here. The surface reads the request on mount. @@ -915,6 +921,9 @@ export default function Layout(props: ParentProps) { onNew={() => openPawworkHome(options?.directory)} onSearch={() => command.show()} onOpenProject={chooseProject} + onOpenSkills={toggleSkills} + skillsActive={skillsOpen} + skillsLabel={() => language.t("sidebar.pawwork.skills")} onOpenAutomations={toggleAutomations} automationsActive={automationsOpen} automationsLabel={() => language.t("sidebar.pawwork.automations")} @@ -949,10 +958,13 @@ export default function Layout(props: ParentProps) { value={{ settingsOpen, automationsOpen, + skillsOpen, openNewSession: openPawworkHome, openSession: navigateToSession, openSettings, closeSettings, + openSkills: () => setActiveSurface("skills"), + closeSkills: () => setActiveSurface("none"), }} > ), }} + skills={{ + open: skillsOpen, + title: () => language.t("skills.title"), + content: () => ( + currentProject()?.worktree ?? projectRoot(currentDir())} + onClose={closeSettings} + /> + ), + }} main={() => ( }> {props.children} diff --git a/packages/app/src/pages/layout/layout-shell-frame.tsx b/packages/app/src/pages/layout/layout-shell-frame.tsx index a5ec438f4..e3fc05295 100644 --- a/packages/app/src/pages/layout/layout-shell-frame.tsx +++ b/packages/app/src/pages/layout/layout-shell-frame.tsx @@ -36,6 +36,11 @@ type LayoutShellFrameProps = { title: Accessor content: () => JSXElement } + skills: { + open: Accessor + title: Accessor + content: () => JSXElement + } main: () => JSXElement } @@ -50,8 +55,14 @@ export function LayoutShellFrame(props: LayoutShellFrameProps) { // Settings replaces both sidebar and main; automations only takes over main // and keeps the session sidebar interactive. mainSurfaceOpen covers either. - const mainSurfaceOpen = createMemo(() => props.settings.open() || props.automations.open()) - const surfaceTitle = createMemo(() => (props.automations.open() ? props.automations.title() : props.settings.title())) + const mainSurfaceOpen = createMemo(() => props.settings.open() || props.automations.open() || props.skills.open()) + const surfaceTitle = createMemo(() => + props.automations.open() + ? props.automations.title() + : props.skills.open() + ? props.skills.title() + : props.settings.title(), + ) createEffect(() => { const dialogLeftMargin = props.sidebar.visible() ? sidebarWidth() : 0 @@ -159,6 +170,9 @@ export function LayoutShellFrame(props: LayoutShellFrameProps) {
{props.automations.content()}
+ +
{props.skills.content()}
+
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")} + + + + +
+
+ + + +
+
+ {skillTitle(props.skill.name)} + {language.t("skills.detail.suffix")} +
+
{props.skill.name}
+
+
+ +

{props.skill.description}

+
+
+ +
+ +
+ + +
+ {props.skill.location} + {props.footer} +
+
+ + + ) +} diff --git a/packages/app/src/pages/skills/skill-presentation.ts b/packages/app/src/pages/skills/skill-presentation.ts new file mode 100644 index 000000000..86770700b --- /dev/null +++ b/packages/app/src/pages/skills/skill-presentation.ts @@ -0,0 +1,37 @@ +// Presentation helpers for the Skills gallery. Skills are rendered straight from +// their own universal format (name + description); there is no curation layer. + +export interface SkillInfo { + name: string + description?: string + location: string + content: string +} + +// `officecli-docx` -> `Officecli Docx`. Split on - and _, capitalize each word. +export function skillTitle(name: string): string { + return name + .split(/[-_]/) + .filter(Boolean) + .map((word) => word[0]!.toUpperCase() + word.slice(1)) + .join(" ") +} + +// Row subtitle. This is the single seam where a future generic summary +// derivation can land (strip "Use this skill…" / "Trigger on…" boilerplate +// mechanically, no per-skill curation). v1 is an identity passthrough on the +// raw description so the gallery stays fully format-driven. +export function skillSummary(skill: Pick): string { + return skill.description?.trim() ?? "" +} + +// Case-insensitive match across title, raw name, and description. +export function skillMatches(skill: SkillInfo, query: string): boolean { + const needle = query.trim().toLowerCase() + if (!needle) return true + return ( + skill.name.toLowerCase().includes(needle) || + skillTitle(skill.name).toLowerCase().includes(needle) || + (skill.description?.toLowerCase().includes(needle) ?? false) + ) +} diff --git a/packages/app/src/pages/skills/skills-surface.tsx b/packages/app/src/pages/skills/skills-surface.tsx new file mode 100644 index 000000000..55504bf17 --- /dev/null +++ b/packages/app/src/pages/skills/skills-surface.tsx @@ -0,0 +1,118 @@ +import { type Accessor, createMemo, createResource, createSignal, For, type JSX, onCleanup, onMount, Show } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import { useGlobalSDK } from "@/context/global-sdk" +import { useLanguage } from "@/context/language" +import { SkillDetail } from "./skill-detail" +import { skillMatches, skillSummary, skillTitle, type SkillInfo } from "./skill-presentation" + +// One capability row: cream tile + brand glyph, humanized title, one-line +// (clamped) description. Borderless; the hover tint is the only affordance. +function SkillRow(props: { skill: SkillInfo; onOpen: () => void }): JSX.Element { + return ( + + ) +} + +export function SkillsSurface(props: { directory: Accessor; onClose: () => void }): JSX.Element { + const globalSDK = useGlobalSDK() + const language = useLanguage() + const [query, setQuery] = createSignal("") + const [selected, setSelected] = createSignal() + + 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 + }) + + // Escape closes the open detail first, then the surface. The sidebar stays + // live behind this surface, so transient overlays get Escape ahead of us. + onMount(() => { + const onEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return + if ( + document.querySelector( + '[data-component="dialog-overlay"], [data-component="select-content"], [data-component="dropdown-menu-content"], [data-component="context-menu-content"]', + ) + ) + return + event.preventDefault() + if (selected()) { + setSelected(undefined) + return + } + props.onClose() + } + document.addEventListener("keydown", onEscape, true) + onCleanup(() => document.removeEventListener("keydown", onEscape, true)) + }) + + return ( +
+
+
+
+

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

+

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

+
+ +
+ + 0} + fallback={ +
+ {language.t("skills.empty.title")} +
+ } + > +
+ {(skill) => setSelected(skill)} />} +
+
+
+ + + {(skill) => setSelected(undefined)} />} + +
+ ) +} From 8cc3eb546fa5cc4ba23f6cd5155dd29023909ed6 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 5 Jun 2026 12:09:35 +0800 Subject: [PATCH 02/13] feat(app): activate a gallery skill via the inline skill chip Add a primary "Use in chat" action to the skill detail. Clicking it leaves the Skills surface, opens a fresh session in the current project, and seeds the composer with the same structured skill chip the slash picker inserts (`{ type: "skill", name, source: "skill" }`), routed via a new ?skill= search param that the session bootstrap consumes. Activation is therefore deterministic: the picked skill is the skill that loads. This deliberately avoids a primed natural-language prompt, which would fall back to the model matching the skill description and could load nothing, the wrong skill, or a different one than the user clicked. Listing (GET /skill) and activation (the skill chip) stay orthogonal: the gallery reads from one, "Use in chat" writes through the other, adding no new activation mechanism. The ?skill= bootstrap reuses the existing generic route-prompt bootstrap hook, so no new seam is introduced. E2E (e2e/skills/skills-panel.spec.ts): the sidebar entry opens the gallery, Escape closes the detail then the surface, and "Use in chat" opens a new session with the summarize skill chip inserted (bare label, no leading slash). Detail footer button reviewed in the refreshed skills-surface snap. --- packages/app/e2e/skills/skills-panel.spec.ts | 70 +++++++++++++++++++ packages/app/src/i18n/en.ts | 1 + packages/app/src/i18n/zh.ts | 1 + packages/app/src/pages/layout.tsx | 11 +++ packages/app/src/pages/session.tsx | 14 +++- .../app/src/pages/skills/skills-surface.tsx | 19 ++++- 6 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 packages/app/e2e/skills/skills-panel.spec.ts 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..a2a39dcc2 --- /dev/null +++ b/packages/app/e2e/skills/skills-panel.spec.ts @@ -0,0 +1,70 @@ +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() + + await page.keyboard.press("Escape") + await expect(surface).toHaveCount(0) +}) + +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/src/i18n/en.ts b/packages/app/src/i18n/en.ts index d7003e7f9..61c200b7d 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -801,6 +801,7 @@ export const dict = { "skills.search.placeholder": "Search skills", "skills.empty.title": "No skills match your search", "skills.detail.suffix": "Skill", + "skills.detail.useInChat": "Use in chat", "automations.title": "Automations", "automations.empty.title": "No automations yet", "automations.empty.description": "Ask the assistant in a chat to automate a task for this project.", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index eb0a8f6ed..354ca8935 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -711,6 +711,7 @@ export const dict = { "skills.search.placeholder": "搜索技能", "skills.empty.title": "没有匹配的技能", "skills.detail.suffix": "技能", + "skills.detail.useInChat": "在对话中使用", "automations.title": "定时任务", "automations.empty.title": "还没有定时任务", "automations.empty.description": "在对话里让助手把当前项目的某个任务设为自动运行。", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 5c016caca..bb19ff8a5 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -517,6 +517,16 @@ export default function Layout(props: ParentProps) { navigate(`/${base64Encode(directory)}/session?prompt=${prompt}`) } + // "Use in chat" from the Skills gallery leaves the surface and starts a fresh + // session in the current project. The ?skill= bootstrap seeds the composer with + // the structured skill chip, so the picked skill activates deterministically. + function useSkillInChat(name: string) { + closeSettings() + const directory = currentProject()?.worktree ?? projectRoot(currentDir()) + if (!directory) return + navigate(`/${base64Encode(directory)}/session?skill=${encodeURIComponent(name)}`) + } + function openSettings(tab?: SettingsTab) { shellNavigation.openSettings(tab) } @@ -1011,6 +1021,7 @@ export default function Layout(props: ParentProps) { currentProject()?.worktree ?? projectRoot(currentDir())} onClose={closeSettings} + onUseSkill={useSkillInChat} /> ), }} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 8da11844d..e9b830ea6 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -59,7 +59,7 @@ export default function Page() { const comments = useComments() const terminal = useTerminal() const location = useLocation() - const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() + const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string; skill?: string }>() const { params, tabs, view } = useSessionLayout() useSessionDesktopContext({ @@ -81,6 +81,18 @@ export default function Page() { clearPrompt: () => setSearchParams({ ...searchParams, prompt: undefined }), }) + // "Use in chat" from the Skills gallery lands here with ?skill=. Seed the + // composer with the same structured skill chip the slash picker inserts, so + // activation is deterministic (this exact skill loads, not a description match). + useSessionRoutePromptBootstrap({ + ready: prompt.ready, + sessionID: () => params.id, + prompt: () => searchParams.skill, + setPrompt: (name) => + prompt.set([{ type: "skill", name, source: "skill", content: `/${name}`, start: 0, end: name.length + 1 }], name.length + 1), + clearPrompt: () => setSearchParams({ ...searchParams, skill: undefined }), + }) + const isDesktop = createMediaQuery("(min-width: 768px)") const size = createSizing() const desktopSidePanelOpen = createMemo(() => isDesktop() && view().sidePanel.opened()) diff --git a/packages/app/src/pages/skills/skills-surface.tsx b/packages/app/src/pages/skills/skills-surface.tsx index 55504bf17..e845e460e 100644 --- a/packages/app/src/pages/skills/skills-surface.tsx +++ b/packages/app/src/pages/skills/skills-surface.tsx @@ -1,4 +1,5 @@ import { type Accessor, createMemo, createResource, createSignal, For, type JSX, onCleanup, onMount, Show } from "solid-js" +import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { useGlobalSDK } from "@/context/global-sdk" import { useLanguage } from "@/context/language" @@ -29,7 +30,11 @@ function SkillRow(props: { skill: SkillInfo; onOpen: () => void }): JSX.Element ) } -export function SkillsSurface(props: { directory: Accessor; onClose: () => void }): JSX.Element { +export function SkillsSurface(props: { + directory: Accessor + onClose: () => void + onUseSkill: (name: string) => void +}): JSX.Element { const globalSDK = useGlobalSDK() const language = useLanguage() const [query, setQuery] = createSignal("") @@ -111,7 +116,17 @@ export function SkillsSurface(props: { directory: Accessor; onClose: () - {(skill) => setSelected(undefined)} />} + {(skill) => ( + setSelected(undefined)} + footer={ + + } + /> + )} ) From 452b6ac59ac43733396a693090298551d7567b07 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 5 Jun 2026 12:44:53 +0800 Subject: [PATCH 03/13] fix(app): hide titlebar chrome behind the skills surface The skills surface is a main-region takeover that keeps the session sidebar live, exactly like automations. But the titlebar/sidebar chrome that those portals and route-active highlights render only suppressed themselves for settings and automations, not skills, so opening Skills left the titlebar's "New session" label, the right-utility toggle, the right-panel tab strip, and the sidebar route-active highlight showing over the surface. Add skillsOpen() alongside the existing automationsOpen() guards so the skills takeover presents the same clean chrome as automations: - session-header.tsx: left + right titlebar portals - right-panel-tab-strip.tsx: the right-panel tab strip portal - sidebar-items.tsx: route-active suppression on the session/new-session rows Verified by an A/B snap of both surfaces open on the same route + sidebar state: the skills titlebar now matches automations (center title only). --- packages/app/src/components/session/session-header.tsx | 4 ++-- packages/app/src/pages/layout/sidebar-items.tsx | 8 ++++---- packages/app/src/pages/session/right-panel-tab-strip.tsx | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 0f7655e46..88da4e225 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) => (