Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion packages/app/e2e/session/titlebar-right-rail-contract.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down
121 changes: 121 additions & 0 deletions packages/app/e2e/skills/skills-panel.spec.ts
Original file line number Diff line number Diff line change
@@ -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")
})
107 changes: 107 additions & 0 deletions packages/app/e2e/snap/skills-surface.snap.ts
Original file line number Diff line number Diff line change
@@ -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/<name>/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`)
})
4 changes: 2 additions & 2 deletions packages/app/src/components/session/session-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export function SessionHeader() {

return (
<>
<Show when={!shellSurface.settingsOpen() && !shellSurface.automationsOpen() && leftMount()}>
<Show when={!shellSurface.mainSurfaceOpen() && leftMount()}>
{(mount) => (
<Portal mount={mount()}>
<div class="hidden md:flex w-full min-w-0 max-w-[720px] items-center overflow-hidden text-h3">
Expand Down Expand Up @@ -118,7 +118,7 @@ export function SessionHeader() {
</Portal>
)}
</Show>
<Show when={!shellSurface.settingsOpen() && !shellSurface.automationsOpen() && rightMount()}>
<Show when={!shellSurface.mainSurfaceOpen() && rightMount()}>
{(mount) => (
<Portal mount={mount()}>
<Show
Expand Down
11 changes: 10 additions & 1 deletion packages/app/src/components/titlebar.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { createEffect, createMemo, Show, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
Expand All @@ -10,13 +10,15 @@
import { isDesktopShell, isMacShell, isWindowsShell, shellAttrs, usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useShellSurface } from "@/context/shell-surface"
import { applyPath, backPath, forwardPath } from "./titlebar-history"

export function Titlebar() {
const layout = useLayout()
const platform = usePlatform()
const command = useCommand()
const language = useLanguage()
const shell = useShellSurface()
const navigate = useNavigate()
const location = useLocation()
const params = useParams()
Expand All @@ -36,8 +38,15 @@
// tabs slot would still claim panel-width on home/settings (where
// SessionSidePanel doesn't render any tabs), pushing the right utility
// toggle's StatusPopover fallback to the left.
// A main-takeover surface (settings / automations / skills) covers the session
// and its right panel, so the rail's reserved width + border-l must retract
// even though the route is still /session and the panel is still "opened".
const tabsRailActive = createMemo(
() => isDesktop() && location.pathname.includes("/session") && layout.rightPanel.opened(),
() =>
isDesktop() &&
location.pathname.includes("/session") &&
layout.rightPanel.opened() &&
!shell.mainSurfaceOpen(),
)
const tabsRailWidth = () => (tabsRailActive() ? "var(--right-panel-width, 0px)" : "0px")
const zoom = () => platform.webviewZoom?.() ?? 1
Expand Down
8 changes: 8 additions & 0 deletions packages/app/src/context/shell-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,18 @@ import type { SettingsTab } from "../pages/settings/settings-shell"
export type ShellSurfaceContextValue = {
settingsOpen: Accessor<boolean>
automationsOpen: Accessor<boolean>
skillsOpen: Accessor<boolean>
// True while any main-region takeover surface (settings / automations /
// skills) covers the session. Single source of truth for "the session and its
// right panel are hidden", so titlebar/sidebar chrome that belongs to the
// covered session retracts uniformly instead of each call site re-deriving it.
mainSurfaceOpen: Accessor<boolean>
openNewSession: (directory?: string) => void
openSession: (session: Session | undefined) => void
openSettings: (tab?: SettingsTab) => void
closeSettings: () => void
openSkills: () => void
closeSkills: () => void
}

export const ShellSurfaceContext = createContext<ShellSurfaceContextValue>()
Expand Down
8 changes: 8 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -794,7 +794,15 @@ 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 a skill to see when it helps and how it works.",
"skills.search.placeholder": "Search skills",
"skills.empty.title": "No skills match your search",
"skills.error.title": "Couldn't load skills",
"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.",
Expand Down
10 changes: 10 additions & 0 deletions packages/app/src/i18n/zh-branding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,14 @@ describe("zh branding copy", () => {
expect(zh[key]).not.toContain("PawWork")
}
})

test("never ships a standalone PawWork anywhere in the Chinese dict", () => {
// The curated list above only spot-checks a few surfaces; the brand rule is
// global — every Chinese string says 爪印, never PawWork. Scan the whole dict
// so a new key can't reintroduce the English name unnoticed.
const offenders = Object.entries(zh)
.filter(([, value]) => typeof value === "string" && value.includes("PawWork"))
.map(([key]) => key)
expect(offenders).toEqual([])
})
})
Loading
Loading