feat(app): add a skills capability gallery#1176
Conversation
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.
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.
|
Warning Review limit reached
More reviews will be available in 53 minutes and 11 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (16)
📝 WalkthroughWalkthroughThis PR implements a complete Skills UI surface for browsing and selecting skills from the desktop app. Users can open a searchable gallery from the sidebar, view skill details in a modal, and initiate a chat session with a selected skill already seeded into the prompt composer via a query parameter. ChangesSkills UI and In-Chat Integration
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Suggested priority: P2 (includes user-path files (packages/app/src/context/shell-surface.tsx, packages/app/src/i18n/en.ts, packages/app/src/i18n/zh.ts, packages/app/src/pages/layout.tsx, packages/app/src/pages/layout/layout-shell-frame.tsx, packages/app/src/pages/layout/pawwork-sidebar-top.tsx, packages/app/src/pages/layout/pawwork-sidebar.tsx, packages/app/src/pages/session.tsx, packages/app/src/pages/skills/skill-detail.tsx, packages/app/src/pages/skills/skill-presentation.ts, packages/app/src/pages/skills/skills-surface.tsx)).
P1/P0 are reserved for maintainer confirmation. Please relabel manually if this is a release blocker, security issue, data-loss risk, or updater/runtime failure.
There was a problem hiding this comment.
Code Review
This pull request introduces a new "Skills" gallery and detail view, allowing users to browse, search, and activate project-scoped skills directly in chat sessions. It adds the SkillsSurface and SkillDetail components, integrates them into the main shell layout and sidebar, supports a ?skill query parameter to seed the chat composer, and includes comprehensive E2E and snapshot tests. The reviewer suggested adding a loading guard to the skills list to prevent the empty state UI from showing prematurely while the data is still loading.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
There was a problem hiding this comment.
🧹 Nitpick comments (3)
packages/app/src/i18n/en.ts (1)
800-800: ⚡ Quick winClarify the subtitle for non-technical users.
The phrase "when it fires" uses technical jargon that may not be clear to the non-technical users mentioned in the PR objectives (
#220). Additionally, "Open one" has a vague antecedent.Consider rewording for clarity:
- "What PawWork can do. Open a skill to see when it activates and how it works."
- "What PawWork can do. Select a skill to see its trigger conditions and how it works."
📝 Suggested rewording
- "skills.subtitle": "What PawWork can do. Open one to see when it fires and how.", + "skills.subtitle": "What PawWork can do. Open a skill to see when it activates and how it works.",🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/app/src/i18n/en.ts` at line 800, Update the "skills.subtitle" string in the i18n key to remove technical jargon and clarify the antecedent; replace "What PawWork can do. Open one to see when it fires and how." with a clearer phrasing such as "What PawWork can do. Select a skill to see when it activates and how it works." (modify the value for the "skills.subtitle" key in packages/app/src/i18n/en.ts accordingly).packages/app/e2e/snap/skills-surface.snap.ts (2)
95-97: ⚡ Quick winPrefer locator assertion over
page.waitForFunction()for element absence.Checking element absence can be more idiomatically expressed using Playwright's built-in locator count assertion instead of raw DOM queries.
♻️ Recommended refactor using locator assertion
- await page.waitForFunction( - () => document.querySelectorAll('[data-action="skill-open"][data-skill="web-research"]').length === 0, - ) + await expect(page.locator('[data-action="skill-open"][data-skill="web-research"]')).toHaveCount(0)As per coding guidelines: "Wait for observable state with locator assertions,
expect.poll(...), or existing helpers in E2E tests rather than using arbitrary timeouts".🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/app/e2e/snap/skills-surface.snap.ts` around lines 95 - 97, Replace the raw DOM check using page.waitForFunction with Playwright locator assertion: target the selector '[data-action="skill-open"][data-skill="web-research"]' via page.locator(...) and await expect(...).toHaveCount(0) so the test waits for absence using Playwright's observable locator APIs instead of page.waitForFunction.
79-79: ⚡ Quick winPrefer
expect.poll()overpage.waitForFunction()for count assertions.The coding guidelines recommend using
expect.poll(...)for waiting on observable state. This assertion can be more clearly expressed with Playwright's polling mechanism.♻️ Recommended refactor using expect.poll()
- await page.waitForFunction(() => document.querySelectorAll('[data-action="skill-open"]').length >= 6) + await expect.poll(() => rows.count()).toBeGreaterThanOrEqual(6)As per coding guidelines: "Wait for observable state with locator assertions,
expect.poll(...), or existing helpers in E2E tests rather than using arbitrary timeouts".🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/app/e2e/snap/skills-surface.snap.ts` at line 79, Replace the direct use of page.waitForFunction for the skill count check with Playwright's expect.poll-based polling: locate the selector used in the wait (document.querySelectorAll('[data-action="skill-open"]')) and use expect.poll to repeatedly evaluate its length/count and assert it is >= 6; update the test to call expect.poll(() => /* count retrieval */).toBeGreaterThanOrEqual(6) (or use locator.count() inside the poll) so the assertion uses Playwright's polling mechanism instead of page.waitForFunction.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/app/e2e/snap/skills-surface.snap.ts`:
- Around line 95-97: Replace the raw DOM check using page.waitForFunction with
Playwright locator assertion: target the selector
'[data-action="skill-open"][data-skill="web-research"]' via page.locator(...)
and await expect(...).toHaveCount(0) so the test waits for absence using
Playwright's observable locator APIs instead of page.waitForFunction.
- Line 79: Replace the direct use of page.waitForFunction for the skill count
check with Playwright's expect.poll-based polling: locate the selector used in
the wait (document.querySelectorAll('[data-action="skill-open"]')) and use
expect.poll to repeatedly evaluate its length/count and assert it is >= 6;
update the test to call expect.poll(() => /* count retrieval
*/).toBeGreaterThanOrEqual(6) (or use locator.count() inside the poll) so the
assertion uses Playwright's polling mechanism instead of page.waitForFunction.
In `@packages/app/src/i18n/en.ts`:
- Line 800: Update the "skills.subtitle" string in the i18n key to remove
technical jargon and clarify the antecedent; replace "What PawWork can do. Open
one to see when it fires and how." with a clearer phrasing such as "What PawWork
can do. Select a skill to see when it activates and how it works." (modify the
value for the "skills.subtitle" key in packages/app/src/i18n/en.ts accordingly).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 2cbba17a-6a7d-45e9-b49f-d102d84e7208
📒 Files selected for processing (13)
packages/app/e2e/skills/skills-panel.spec.tspackages/app/e2e/snap/skills-surface.snap.tspackages/app/src/context/shell-surface.tsxpackages/app/src/i18n/en.tspackages/app/src/i18n/zh.tspackages/app/src/pages/layout.tsxpackages/app/src/pages/layout/layout-shell-frame.tsxpackages/app/src/pages/layout/pawwork-sidebar-top.tsxpackages/app/src/pages/layout/pawwork-sidebar.tsxpackages/app/src/pages/session.tsxpackages/app/src/pages/skills/skill-detail.tsxpackages/app/src/pages/skills/skill-presentation.tspackages/app/src/pages/skills/skills-surface.tsx
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).
Opening a main-takeover surface (settings / automations / skills) from a
/session route that has the right utility panel open leaked the panel's
titlebar chrome over the surface: a 1px border-l divider, ~380px of reserved
rail width, and — under settings — the still-portalled tab strip ("Status").
Root cause: the right panel's titlebar chrome derived its visibility from
scattered, inconsistent conditions, none of which knew a surface was
covering the still-mounted session and its panel. titlebar's tabsRailActive
only checked `/session route && rightPanel.opened()`; right-panel-tab-strip
gated automations/skills but not settings. The prior fix only patched
session-header's two portals, so it missed the rail's own width/border-l and
the settings tab-strip leg — three separate chrome traces, one patched.
Fix: add a single source of truth `mainSurfaceOpen` to the shell-surface
context (derived from `activeSurface`), and have every piece of session
chrome a takeover covers read it: titlebar tabsRailActive, right-panel-tab-
strip, session-header's left/right portals (collapsed from the three-flag
form), and sidebar route-active suppression. Future surfaces only update the
one derivation.
`--right-panel-width` is left untouched on purpose: it is the panel's real
geometry, not a chrome-visibility flag. Zeroing it would animate the rail
0→380px on surface close against a body already at 380px (seam mismatch).
Only the rail's active state retracts.
Verified: new case in titlebar-right-rail-contract.spec.ts opens the right
panel then each of settings/automations/skills and asserts
#pawwork-titlebar-tabs has width 0, borderLeft 0px, 0 children — red before
(width 380), green after. Visually confirmed the divider and the settings
status-tab remnant are gone.
The skills gallery and "Use in chat" navigation resolved against the owner project root (currentProject().worktree), mirroring the Automations surface. But skills are directory-resolved capabilities, not project entities: the composer's slash picker queries them against the active route directory (the SDK directory). In a sandbox/workspace sub-route the two diverge, so the gallery could list a different skill set than the composer offers, and "Use in chat" dropped the user from their active workspace into a root session. Resolve skills against the active currentDir() like the composer does, falling back to the project root only when no directory is active.
The gallery rendered the empty-state copy ("No skills match your search")
whenever filtered() was empty. While the resource is still resolving its
first batch, skills() is undefined and filtered() is empty, so every normal
load flashed "no skills" before the list appeared, and a load failure was
mislabeled as "no skills" with no way to tell the two apart.
Key the body off the resource state: a height-reserving placeholder while
loading (no flicker, no "Loading…" label since local skills resolve fast),
a dedicated message on failure, and the empty copy only once a real
filtered-to-zero result is in hand.
…session
Two independent route bootstraps seed the composer from ?skill= and ?prompt=.
The in-app entry points never set both ("Use in chat" sets only skill,
"Create via chat" only prompt), but a hand-built deep link could carry both,
and which one won was left to bootstrap definition order. Make the precedence
explicit — skill wins — so a combined link deterministically inserts the skill
chip instead of a text prompt.
The skill detail reader was a hand-rolled overlay: its own scrim, click-outside close, stopPropagation, and role/aria-modal, plus a keydown listener on the surface that sniffed the DOM for overlay components to decide whether Escape should close the detail or the surface. Mount it through useDialog().show instead, so the dialog base owns the modal shell — overlay, focus trap, initial focus, focus restore, background inert, Escape — and the detail only composes its content. The surface's Escape handler now defers to a detailOpen() signal driven by the dialog's onClose (which fires synchronously on close, unlike dialog.active, which lingers through the exit animation), replacing the brittle DOM sniff. This also gives the reader the correct full-window modal semantics its aria-modal already claimed. Trade-off: the reader now covers the whole window (standard modal) rather than only the surface region; accepted for the accessibility and consistency win.
…trap The prior "skill wins" guard only stopped the prompt bootstrap from seeding while ?skill= was still present. But the skill bootstrap cleared only the skill param, which flipped that guard back off — the surviving ?prompt= then became visible to the prompt accessor and re-seeded the composer a beat later, overwriting the skill chip with plain text. Clear both params when the skill is consumed so a combined deep link (?skill=summarize&prompt=hello) deterministically lands the skill chip and the stray prompt text never resurfaces.
…tail The skills surface's Escape handler tracked only its own detail reader (detailOpen), so Escape would wrongly close the surface when another modal was up. That happens in practice: the sidebar stays live behind the surface, and its search calls command.show() directly — bypassing the keybind gate that suppresses ⌘K palettes behind a surface — so a command palette can sit on top of the gallery. Defer to dialog.active instead: the single source of truth for "a shared dialog-stack modal owns Escape", covering both the detail reader and a sidebar-opened palette, and replacing the old DOM overlay sniff. Trade-off: dialog.active lingers through useDialog's ~100ms exit grace, so an Escape fired within that window after a modal closes is absorbed rather than closing the surface — negligible for a real user, and the e2e waits the grace out. Adds regression tests: a palette opened over the gallery consumes Escape before the surface, and the combined ?skill=&prompt= deep link seeds only the chip.
Two-up list rows now pair the humanized title with a two-line clamped description, replacing the cramped single-line subtitle that packed the band. The detail reader moves the frontmatter description into a bordered cream summary card set apart from the SKILL.md body so the machine-facing blurb no longer blurs into it, and drops the raw slug from the header — it only echoed the humanized title and still shows in the footer path. Removes the now-unused skillSummary passthrough.
- zh subtitle: use the 爪印 brand name (the established Chinese product
name throughout zh.ts) and drop the "触发" jargon, per a DeepSeek-v4-pro
copy review — was "PawWork 能做的事。…何时触发、如何工作。"
- en subtitle: name the antecedent ("Open a skill") and replace the
"fires" jargon with "when it helps and how it works" (CodeRabbit)
- snap e2e: wait on observable state via expect.poll(...).toBeGreaterThan
Equal and expect(locator).toHaveCount(0) instead of page.waitForFunction,
matching the repo's E2E guideline (CodeRabbit)
The integrations settings description still read "PawWork" while every other Chinese string uses 爪印. Also widen the branding test from a five-key spot-check to a full scan of the dict, so a stray "PawWork" can't slip back into any Chinese string unnoticed.
Summary
Adds a top-level Skills surface to the PawWork sidebar (between Search and Automations) that shows what the agent can do, and a deterministic way to start using a skill.
GET /skillroute (client.app.skills) and renders grouped two-column borderless icon rows. Each row humanizes the skillnameinto a readable title and shows thedescriptionclamped to one line. A search box filters across title, name, and description.descriptionverbatim, and the fullSKILL.mdbody via the shared@opencode-ai/ui/markdownrenderer (code blocks with copy). No prev/next nav.{ type: "skill", name, source: "skill" }), via a new?skill=route param. The picked skill is the skill that loads.Generic, format-driven rendering with no per-skill curation: skills are unbounded (a user may author hundreds), so nothing hand-matches per skill.
skillSummary()is the single isolated seam where a future schema-free summary derivation can land without touching skills or the data model.Why
For a non-technical everyday-work user, the dominant unmet need is discovery ("what can this thing actually do for me"), not toggling skills on and off. v1 makes capability visible; management (enable/disable) and a marketplace are deferred layers. The full design direction, alternatives considered, and rejected options are recorded on #220.
Activation reuses the existing inline slash-skill chip (#1156) rather than a primed natural-language prompt: a sentence 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 and add no new mechanism.Related Issue
Closes #220.
Human Review Status
Pending
Review Focus
activeSurfacesignal,LayoutShellFrameskills slot, sidebar entry,ShellSurfacecontext. The session sidebar stays live behind the surface; Escape closes the open detail first, then the surface.?skill=bootstrap reuses the existing generic route-prompt bootstrap hook (useSessionRoutePromptBootstrap) rather than adding a parallel seam — confirm that reuse reads cleanly.skillSummary()seam (identity passthrough in v1). See [Feature] Skill management GUI in the desktop app #220.Risk Notes
permission.skillor introduce a disabled-skills field; no enable/disable. The gallery currently listsSkill.all()viaGET /skill; switching the source to agent-available skills is noted as a follow-up on [Feature] Skill management GUI in the desktop app #220.SKILL.mdbody, which can expose setup commands and agent-facing rules to end users. Accepted for v1; revisit if it reads poorly.How To Verify
Screenshots or Recordings
Visual check via the added
skills-surfacesnap target (real backend skills + seeded project skills). Reviewed grid (gallery / detail modal / filtered search) atdocs/design/preview/screenshots/skills-surface.png. Surfaces checked: the capability gallery, the detail modal with the "Use in chat" footer, and the search-filtered grid.Checklist
bug,enhancement,task,documentation.app,ui,platform,harness,ci.P0,P1,P2,P3.Pending,Approved by @<reviewer>, orNot required: <reason>.dev, and my PR title and commit messages use Conventional Commits in English.Summary by CodeRabbit
New Features
Tests