Choose which orchestration skills get installed (#2680)

* feat(settings): choose which orchestration skills to install

The skills row installed the whole bundle or nothing. A settings control
next to it now opens a sheet where you keep every bundled skill or pick a
subset, and saving converges ~/.agents, ~/.claude, and ~/.codex to it.

"All skills" reads the bundle directory instead of a hardcoded catalog, so
a skill added in a later release installs itself without another code
change. Retired bundle names stay in a separate cleanup list so old copies
on disk are still removed but never become selectable.

The selection persists under Electron user data, separate from install
state, so uninstalling does not erase the choice — a later install brings
back exactly the chosen set. Installs with no saved selection read as
"all", which is what they already had.

* Restore skill selection after failed save

* fix(settings): don't save a skill selection that never reached disk

A failed save wrote the preference, converged, then wrote the old preference
back. That leaves a window where a selection the user was told did not save
is the one on disk, and a crash inside it makes the window permanent.
Converge first and persist only on success — nothing to roll back, and a
failed save now leaves disk and preference exactly as Cancel would.

Resolving the bundle path when the command handlers are wired also broke
daemon-manager's tests: the packaged branch reads process.resourcesPath,
which is undefined outside Electron. Resolve targets per command instead —
registering handlers should not touch the environment.

* fix(settings): make skills convergence single-writer and confirm removals

Startup auto-update and the settings commands were separate writers over the
same skill directories. Either could apply a plan computed from a selection the
other had already replaced — narrow the selection while startup is repairing
drift and the repair puts the removed skill back. There is now one controller
that owns scan, apply, and persist, and every entry point queues through it, so
concurrent callers see a consistent result instead of an interleaved one.

Save also had no recovery once it had converged: if the selection could not be
committed afterwards, disk was changed while the preference still said the old
thing, and the next startup silently undid the change. Save now puts the
managed directories back to what the committed selection describes before it
reports the failure, so a failed Save lands where Cancel would.

Deselecting a skill deletes its directory in all three agent homes, including
files the user put inside it. That is now confirmed like every other
destructive action, naming the skills and the directories. Adding skills or
saving an unchanged selection still saves without a prompt, and a skill the
host has not installed yet deletes nothing so it does not prompt either.

* fix(settings): confirm removal for skills installed in only some agent homes

The removal confirmation read an `add` op as "not on disk yet". An `add` op
means "missing from at least one target", so a skill sitting in ~/.claude but
not the other two looked absent, and deselecting it deleted that directory —
with anything the user had put inside it — without asking.

The status snapshot now carries the installed skill names directly, taken from
union presence across the three agent homes, and the confirmation reads that.
`ops` keeps its one meaning.

A host that predates the field cannot say what is on disk, so the app bridge
assumes the saved selection is installed. Asking about a directory that turns
out to be absent is harmless; the reverse is not.

* fix(settings): warn about every skill directory a save deletes

The confirmation asked "was this selected before and is it selected now",
which is a different question from the one the host answers. The host
converges to the desired set and deletes every managed directory outside it,
so a skill on disk that the saved preference never mentioned — reinstalled by
hand, restored by a sync, left behind by drift — was deleted with no warning,
user files included.

The predicate now mirrors the host rule: everything installed that the draft
will not keep, regardless of what was saved before. It moved out of the sheet
into its own module so the rule has one owner and can be tested directly.
Saving with nothing to delete still does not prompt.

* fix(settings): a failed save keeps files the user added inside a skill

Save converges before it commits, and convergence deletes whole skill
directories. When the commit then failed, the recovery re-installed the
committed selection — which rebuilds the bundled files and nothing else, so
notes, hooks, and anything else the user had put inside a deselected skill
were gone for good. Cancel does not do that, and a failed save is supposed to
land where Cancel would.

Save now runs as a transaction. Before converging it stages a copy of every
managed skill directory across the three agent homes, next to the tree so the
copy stays on one filesystem. Committing drops the staging; failing anywhere
puts each directory back exactly as it was, deletes the ones the candidate had
created, and then drops the staging. Staging left by a process that died
mid-save is cleaned up by the next one.

The controller only sees begin/commit/rollback; the filesystem work lives in
skills-transaction.ts.

* fix(settings): make skill removal confirmation authoritative

* fix(settings): verify skill convergence before commit

* fix(settings): preserve concurrent skill edits on rollback

* fix(settings): preserve staged skill metadata

* fix(settings): stage skill deletions atomically

* fix(settings): stage removals per filesystem

* fix(settings): restore staged skill symlinks

* fix(settings): canonicalize skill transaction roots

* fix(settings): keep automatic skill updates non-destructive

* fix(settings): report committed skill saves accurately

* fix(settings): keep skill updates non-destructive

* fix(settings): route skill removals through selection

* fix(settings): close skill selection edge cases

* fix(settings): preserve skill data during maintenance

* fix(settings): hide unavailable skill install

* fix(desktop): keep install policy at command boundary

* fix(desktop): restore staged skill directories atomically

* test(app): retry transient skills sandbox cleanup

* fix(desktop): recover externally deleted skill stages

* fix(desktop): preserve conflicting skill rollback data

* fix(desktop): generalize skill rollback conflicts

* fix(desktop): preserve skill rollback collisions

* fix(desktop): recover quarantined skill backups

* fix(app): inset adaptive sheet content on compact

The compact Choose skills sheet rendered edge to edge: the All skills card
touched both screen edges, the section label was glued to it, and the skill
list ran to the edges too. Desktop was correct.

AdaptiveModalSheet expressed its content inset through
BottomSheetScrollView.contentContainerStyle. That is a library prop, not the
style prop Unistyles registers, and gorhom's scroller was never rewritten by
the Babel plugin, so on web the emitted class was never applied and the
padding and gap resolved to nothing. The desktop card uses a real ScrollView,
which Unistyles does track, which is why only compact was affected.

The inset now lives on a View the sheet owns, rendered by every presentation,
so one definition covers compact and desktop and a scroller only ever gets
theme-free layout. The caller-facing prop is contentStyle, since it no longer
lands on a scroll content container.

Choose skills drops its ad-hoc label for SettingsSection, so the label carries
the app's section rhythm instead of sitting equidistant between two cards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(desktop): prevent skill rollback data loss

Preserve externally replaced directory trees during recovery and reject managed child symlinks before synchronization can write outside a skill directory.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2026-07-31 12:28:32 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent f3fee73976
commit 91da0bac4d
36 changed files with 4444 additions and 264 deletions
@@ -0,0 +1,101 @@
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
readRails,
readSheetHeaderRails,
readVerticalGap,
waitForSettledPosition,
} from "./helpers/sheet-layout";
import {
openCompactSkillsIntegrations,
openSkillSelection,
openSkillsIntegrations,
startSkillsSandbox,
type SkillsSandbox,
} from "./helpers/skills-selection";
const COMPACT_VIEWPORT = { width: 390, height: 844 };
const DESKTOP_VIEWPORT = { width: 1280, height: 900 };
const sandboxes: SkillsSandbox[] = [];
test.afterEach(async () => {
for (const sandbox of sandboxes.splice(0)) {
await sandbox.cleanup();
}
});
async function openChooseSkillsOnPhone(page: Page): Promise<void> {
sandboxes.push(await startSkillsSandbox(page));
await gotoAppShell(page);
await openCompactSkillsIntegrations(page);
await openSkillSelection(page);
await waitForSettledPosition(page.getByTestId("skill-selection-sheet"));
}
async function openChooseSkillsOnDesktop(page: Page): Promise<void> {
sandboxes.push(await startSkillsSandbox(page));
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
}
function sheetHeaderRails(page: Page) {
return readSheetHeaderRails(page, "skill-selection-sheet", "Choose skills");
}
function allSkillsCard(page: Page) {
return page.getByTestId("skill-selection-all-card");
}
function skillListCard(page: Page) {
return page.getByTestId("skill-selection-list-card");
}
function bundledSkillsLabel(page: Page) {
return page.getByText("Bundled skills", { exact: true });
}
test.describe("Choose skills sheet on a compact form factor", () => {
test.use({ viewport: COMPACT_VIEWPORT });
test("keeps its cards on the sheet header rails", async ({ page }) => {
await openChooseSkillsOnPhone(page);
const header = await sheetHeaderRails(page);
expect(await readRails(allSkillsCard(page))).toEqual(header);
expect(await readRails(skillListCard(page))).toEqual(header);
});
test("gives the Bundled skills label more room above it than below", async ({ page }) => {
await openChooseSkillsOnPhone(page);
expect({
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
});
});
test.describe("Choose skills sheet on a desktop form factor", () => {
test.use({ viewport: DESKTOP_VIEWPORT });
test("keeps its cards on the sheet header rails", async ({ page }) => {
await openChooseSkillsOnDesktop(page);
const header = await sheetHeaderRails(page);
expect(await readRails(allSkillsCard(page))).toEqual(header);
expect(await readRails(skillListCard(page))).toEqual(header);
});
test("gives the Bundled skills label more room above it than below", async ({ page }) => {
await openChooseSkillsOnDesktop(page);
expect({
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
});
});
+68
View File
@@ -0,0 +1,68 @@
import { expect, type Locator, type Page } from "@playwright/test";
export interface Rails {
left: number;
right: number;
}
interface Box {
x: number;
y: number;
width: number;
height: number;
}
async function readBox(locator: Locator): Promise<Box> {
const box = await locator.boundingBox();
expect(box, "Expected the element to be laid out").not.toBeNull();
return box as Box;
}
/** Leading and trailing edges of an element, in whole pixels. */
export async function readRails(locator: Locator): Promise<Rails> {
const box = await readBox(locator);
return { left: Math.round(box.x), right: Math.round(box.x + box.width) };
}
/**
* The two rails an AdaptiveModalSheet header indents to: the title's leading
* glyph and the close button's trailing edge. Sheet content sits on the same
* two rails, in every presentation.
*/
export async function readSheetHeaderRails(
page: Page,
sheetTestID: string,
title: string,
): Promise<Rails> {
const header = page.getByTestId(sheetTestID);
const [titleRails, closeRails] = await Promise.all([
readRails(header.getByText(title, { exact: true })),
readRails(header.getByRole("button", { name: "Close" })),
]);
return { left: titleRails.left, right: closeRails.right };
}
/** Vertical space between the bottom of one element and the top of the next. */
export async function readVerticalGap(upper: Locator, lower: Locator): Promise<number> {
const [above, below] = await Promise.all([readBox(upper), readBox(lower)]);
return Math.round(below.y - (above.y + above.height));
}
/**
* A compact sheet slides up into place, so anything measured across two reads
* has to wait for the surface to stop moving first.
*/
export async function waitForSettledPosition(locator: Locator): Promise<void> {
let previousTop: number | null = null;
await expect
.poll(
async () => {
const { y } = await readBox(locator);
const settled = previousTop === y;
previousTop = y;
return settled;
},
{ message: "Expected the sheet to stop moving" },
)
.toBe(true);
}
@@ -0,0 +1,410 @@
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { buildOpenProjectRoute } from "@/utils/host-routes";
import { openSettings } from "./app";
import { injectDesktopBridge } from "./desktop-updates";
import { getServerId } from "./server-id";
import { openCompactSettings, openSettingsSection } from "./settings";
// The desktop skills module is the real command surface: the same handlers the
// Electron main process registers, running against a temp bundle and temp user
// data. Nothing about persistence or convergence is simulated in the browser.
import type {
SkillSelection,
SkillTargets,
} from "../../../desktop/src/integrations/skills/operations.js";
import { createSkillSelectionStore } from "../../../desktop/src/integrations/skills/selection-store.js";
import { createSkillsCommandHandlers } from "../../../desktop/src/integrations/skills/skills-commands.js";
import { createSkillsController } from "../../../desktop/src/integrations/skills/skills-controller.js";
const SKILL_COMMANDS = [
"get_skills_status",
"install_skills",
"update_skills",
"uninstall_skills",
"save_skills_selection",
] as const;
declare global {
interface Window {
__paseoSkillsInvoke?: (
command: string,
args: Record<string, unknown> | null,
) => Promise<unknown>;
}
}
export interface SkillsSandbox {
targets: SkillTargets;
userDataPath: string;
bundledSkills: string[];
/** Writes a managed skill directory the way an external tool would. */
placeSkillOnDisk: (skill: string, target: SkillTargetName) => Promise<void>;
/** Commits a selection through the real controller, as another window would. */
commitSelectionExternally: (
selection: SkillSelection,
confirmedRemovals?: readonly string[],
) => Promise<void>;
/** Waits until a held save reaches the desktop command boundary. */
waitForHeldSave: () => Promise<void>;
/** Releases a save held at the desktop command boundary. */
releaseHeldSave: () => void;
/** Test-only command-boundary gate used to keep the renderer in its pending state. */
holdSaveResponse?: () => Promise<void>;
cleanup: () => Promise<void>;
}
export interface SkillsSandboxOptions {
bundledSkills?: string[];
/**
* Puts a regular file where the `.agents` skills tree has to go, so the real
* convergence fails with ENOTDIR instead of a stubbed rejection.
*/
blockAgentsDir?: boolean;
/** Converge and commit this selection before the app loads, through the real controller. */
installed?: SkillSelection;
/**
* Leaves a skill installed in one agent home only, the way a partial install
* or a hand-deleted directory leaves it.
*/
keepOnlyIn?: { skill: string; target: SkillTargetName };
/**
* Writes a skill directory straight into one agent home after the selection is
* committed, the way a manual reinstall or an external sync leaves a directory
* the saved preference does not mention.
*/
placeOnDisk?: { skill: string; target: SkillTargetName; files: Record<string, string> };
/** Holds the save response so dismissal behavior can be asserted while pending. */
holdSave?: boolean;
}
export type SkillTargetName = "agents" | "claude" | "codex";
function targetDir(targets: SkillTargets, target: SkillTargetName): string {
if (target === "agents") return targets.agentsDir;
if (target === "claude") return targets.claudeDir;
return targets.codexDir;
}
export async function createSkillsSandbox(
options: SkillsSandboxOptions = {},
): Promise<SkillsSandbox> {
let releaseHeldSave: (() => void) | null = null;
let markSaveHeld: (() => void) | null = null;
const heldSave = new Promise<void>((resolve) => {
markSaveHeld = resolve;
});
const saveRelease = new Promise<void>((resolve) => {
releaseHeldSave = resolve;
});
const bundledSkills = options.bundledSkills ?? ["paseo", "paseo-advisor", "paseo-loop"];
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-e2e-skills-"));
const targets: SkillTargets = {
sourceDir: path.join(root, "bundle"),
agentsDir: path.join(root, "home", ".agents", "skills"),
claudeDir: path.join(root, "home", ".claude", "skills"),
codexDir: path.join(root, "home", ".codex", "skills"),
};
for (const name of bundledSkills) {
await mkdir(path.join(targets.sourceDir, name), { recursive: true });
await writeFile(path.join(targets.sourceDir, name, "SKILL.md"), `# ${name}\n`, "utf8");
}
const userDataPath = path.join(root, "user-data");
if (options.installed) {
await createSkillsController({
resolveTargets: () => targets,
selectionStore: createSkillSelectionStore({ userDataPath }),
}).save(options.installed);
}
if (options.keepOnlyIn) {
const kept = targetDir(targets, options.keepOnlyIn.target);
for (const dir of [targets.agentsDir, targets.claudeDir, targets.codexDir]) {
if (dir === kept) continue;
await rm(path.join(dir, options.keepOnlyIn.skill), { recursive: true, force: true });
}
}
if (options.placeOnDisk) {
const dir = path.join(
targetDir(targets, options.placeOnDisk.target),
options.placeOnDisk.skill,
);
for (const [rel, contents] of Object.entries(options.placeOnDisk.files)) {
const file = path.join(dir, rel);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, contents, "utf8");
}
}
if (options.blockAgentsDir) {
await mkdir(path.dirname(targets.agentsDir), { recursive: true });
await writeFile(targets.agentsDir, "not a directory", "utf8");
}
return {
targets,
userDataPath,
bundledSkills,
placeSkillOnDisk: async (skill, target) => {
const dir = path.join(targetDir(targets, target), skill);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "SKILL.md"), `# ${skill}\n`, "utf8");
},
commitSelectionExternally: async (selection, confirmedRemovals = []) => {
await createSkillsController({
resolveTargets: () => targets,
selectionStore: createSkillSelectionStore({ userDataPath }),
}).save({ ...selection, confirmedRemovals: [...confirmedRemovals] });
},
waitForHeldSave: () => heldSave,
releaseHeldSave: () => releaseHeldSave?.(),
holdSaveResponse: options.holdSave
? async () => {
markSaveHeld?.();
await saveRelease;
}
: undefined,
// The save path atomically renames skill directories. On Linux/overlayfs,
// recursive removal can briefly observe a repopulated directory and fail
// with ENOTEMPTY even after the command has completed. Node only retries
// that transient when maxRetries is set.
cleanup: () => rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
};
}
/** Reads the committed selection back through the same store the app writes. */
export async function expectSavedSkillSelection(
sandbox: SkillsSandbox,
expected: SkillSelection,
): Promise<void> {
const store = createSkillSelectionStore({ userDataPath: sandbox.userDataPath });
await expect.poll(() => store.get(), { timeout: 15_000 }).toEqual(expected);
}
/**
* Routes the skills desktop commands to the real handlers in Node, leaving every
* other command on the injected desktop bridge. Call after `injectDesktopBridge`
* and before navigation.
*/
export async function serveRealSkillsCommands(page: Page, sandbox: SkillsSandbox): Promise<void> {
const handlers = createSkillsCommandHandlers({
controller: createSkillsController({
resolveTargets: () => sandbox.targets,
selectionStore: createSkillSelectionStore({ userDataPath: sandbox.userDataPath }),
}),
});
await page.exposeFunction(
"__paseoSkillsInvoke",
async (command: string, args: Record<string, unknown> | null) => {
const handler = handlers[command];
if (!handler) throw new Error(`Unknown skills command: ${command}`);
const result = await handler(args ?? undefined);
if (command === "save_skills_selection") {
await sandbox.holdSaveResponse?.();
}
return result;
},
);
await page.addInitScript((commands: readonly string[]) => {
const bridge = (window as unknown as { paseoDesktop?: { invoke: unknown } }).paseoDesktop;
if (!bridge) throw new Error("Desktop bridge must be injected before the skills bridge.");
const skillCommands = new Set(commands);
const inner = bridge.invoke as (
command: string,
args?: Record<string, unknown>,
) => Promise<unknown>;
bridge.invoke = (command: string, args?: Record<string, unknown>) =>
skillCommands.has(command)
? window.__paseoSkillsInvoke!(command, args ?? null)
: inner(command, args);
}, SKILL_COMMANDS);
}
/** Real skills commands over a temp bundle, wired to this page's desktop bridge. */
export async function startSkillsSandbox(
page: Page,
options: SkillsSandboxOptions = {},
): Promise<SkillsSandbox> {
const sandbox = await createSkillsSandbox(options);
await injectDesktopBridge(page, { serverId: getServerId(), confirmShouldAccept: false });
await serveRealSkillsCommands(page, sandbox);
return sandbox;
}
export async function openSkillsIntegrations(page: Page): Promise<void> {
await openSettings(page);
await openSettingsSection(page, "integrations");
await expect(page.getByText("Orchestration skills", { exact: true })).toBeVisible();
}
/** Settings on a compact form factor stacks list and detail, so it needs the menu first. */
export async function openCompactSkillsIntegrations(page: Page): Promise<void> {
await openCompactSettings(page, buildOpenProjectRoute());
await openSettingsSection(page, "integrations");
await expect(page.getByText("Orchestration skills", { exact: true })).toBeVisible();
}
export async function openSkillSelection(page: Page): Promise<void> {
await page.getByRole("button", { name: "Choose skills", exact: true }).click();
await expectSkillSelectionOpen(page);
}
export async function expectSkillSelectionOpen(page: Page): Promise<void> {
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toBeVisible();
}
export async function expectSkillSelectionClosed(page: Page): Promise<void> {
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toHaveCount(0);
}
export async function chooseAllSkills(page: Page): Promise<void> {
const allSkills = page.getByRole("switch", { name: "All skills", exact: true });
await expect(allSkills).not.toBeChecked();
await allSkills.click();
await expect(allSkills).toBeChecked();
}
export async function chooseCustomSkills(page: Page, skills: string[]): Promise<void> {
const allSkills = page.getByRole("switch", { name: "All skills", exact: true });
await expect(allSkills).toBeChecked();
await allSkills.click();
await expect(allSkills).not.toBeChecked();
const keep = new Set(skills);
for (const name of await listSkillChoices(page)) {
if (keep.has(name)) continue;
await page.getByRole("checkbox", { name, exact: true }).click();
}
await expectSkillChoices(page, skills);
}
export async function toggleSkill(page: Page, name: string): Promise<void> {
await page.getByRole("checkbox", { name, exact: true }).click();
}
export async function saveSkillSelection(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save", exact: true }).click();
}
export async function cancelSkillSelection(page: Page): Promise<void> {
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await expectSkillSelectionClosed(page);
}
export async function listSkillChoices(page: Page): Promise<string[]> {
const rows = page.getByRole("checkbox");
const names = await rows.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("aria-label") ?? ""),
);
return names.filter((name) => name.length > 0);
}
/** Asserts exactly which skills are checked in the open sheet. */
export async function expectSkillChoices(page: Page, skills: string[]): Promise<void> {
const expected = new Set(skills);
for (const name of await listSkillChoices(page)) {
const choice = page.getByRole("checkbox", { name, exact: true });
if (expected.has(name)) {
await expect(choice).toBeChecked();
continue;
}
await expect(choice).not.toBeChecked();
}
}
/** Reopens the saved selection and asserts what the user sees, then closes it. */
export async function expectSelectedSkills(page: Page, skills: string[]): Promise<void> {
await openSkillSelection(page);
await expect(page.getByRole("switch", { name: "All skills", exact: true })).not.toBeChecked();
await expectSkillChoices(page, skills);
await cancelSkillSelection(page);
}
export async function expectAllSkillsSelected(page: Page): Promise<void> {
await openSkillSelection(page);
const allSkills = page.getByRole("switch", { name: "All skills", exact: true });
await expect(allSkills).toBeChecked();
await expectSkillChoices(page, await listSkillChoices(page));
await cancelSkillSelection(page);
}
export async function expectSaveErrorKeepsSheetOpen(page: Page): Promise<void> {
await expect(page.getByText("Could not save your skill selection.", { exact: true })).toBeVisible(
{ timeout: 15_000 },
);
await expectSkillSelectionOpen(page);
}
/**
* The removal warning is the desktop confirm dialog, which has no DOM to query.
* The injected bridge records the call the same way the daemon-management tests
* assert their confirmation copy.
*/
export async function expectRemovalWarning(page: Page, skills: string[]): Promise<void> {
await page.waitForFunction(() => !!window.__capturedDialogCall, { timeout: 15_000 });
const call = await page.evaluate(() => window.__capturedDialogCall);
expect(call?.title).toBe("Remove deselected skills?");
for (const name of skills) {
expect(call?.message).toContain(name);
}
for (const dir of ["~/.agents", "~/.claude", "~/.codex"]) {
expect(call?.message).toContain(dir);
}
expect(call?.message).toContain("Anything you added inside those skill folders is deleted too.");
}
export async function expectNoRemovalWarning(page: Page): Promise<void> {
expect(await page.evaluate(() => window.__capturedDialogCall)).toBeUndefined();
}
export async function expectSkillPresentIn(
sandbox: SkillsSandbox,
target: SkillTargetName,
skill: string,
): Promise<void> {
await expect
.poll(() => readInstalledSkills(targetDir(sandbox.targets, target)), { timeout: 15_000 })
.toContain(skill);
}
export async function expectSkillFilePresent(
sandbox: SkillsSandbox,
target: SkillTargetName,
skill: string,
relativePath: string,
): Promise<void> {
const file = path.join(targetDir(sandbox.targets, target), skill, relativePath);
await expect
.poll(() => readFile(file, "utf8").catch(() => null), { timeout: 15_000 })
.not.toBeNull();
}
export async function expectSkillsInstalled(
sandbox: SkillsSandbox,
skills: string[],
): Promise<void> {
const dirs = [
sandbox.targets.agentsDir,
sandbox.targets.claudeDir,
sandbox.targets.codexDir,
] as const;
for (const dir of dirs) {
await expect
.poll(async () => readInstalledSkills(dir), { timeout: 15_000 })
.toEqual([...skills].sort());
}
}
async function readInstalledSkills(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
}
+333
View File
@@ -0,0 +1,333 @@
import { test as base } from "./fixtures";
import { expect } from "@playwright/test";
import { gotoAppShell } from "./helpers/app";
import { injectDesktopBridge } from "./helpers/desktop-updates";
import { getServerId } from "./helpers/server-id";
import {
cancelSkillSelection,
chooseAllSkills,
chooseCustomSkills,
createSkillsSandbox,
expectAllSkillsSelected,
expectNoRemovalWarning,
expectRemovalWarning,
expectSavedSkillSelection,
expectSaveErrorKeepsSheetOpen,
expectSelectedSkills,
expectSkillChoices,
expectSkillFilePresent,
expectSkillPresentIn,
expectSkillSelectionOpen,
expectSkillsInstalled,
openSkillsIntegrations,
openSkillSelection,
saveSkillSelection,
toggleSkill,
serveRealSkillsCommands,
type SkillsSandbox,
type SkillsSandboxOptions,
} from "./helpers/skills-selection";
interface SkillsSandboxSetup extends SkillsSandboxOptions {
/** What the desktop confirm dialog answers when the removal warning fires. */
confirmRemoval?: boolean;
}
type StartSkills = (setup?: SkillsSandboxSetup) => Promise<SkillsSandbox>;
// Every skills command runs against the real desktop handlers over a temp bundle
// and temp user data, so the assertions cover persistence and convergence, not a
// browser stand-in for them.
const test = base.extend<{ startSkills: StartSkills }>({
startSkills: async ({ page }, provide) => {
const sandboxes: SkillsSandbox[] = [];
await provide(async (setup = {}) => {
const { confirmRemoval, ...options } = setup;
const sandbox = await createSkillsSandbox(options);
await injectDesktopBridge(page, {
serverId: getServerId(),
confirmShouldAccept: confirmRemoval ?? false,
});
await serveRealSkillsCommands(page, sandbox);
sandboxes.push(sandbox);
return sandbox;
});
for (const sandbox of sandboxes) {
await sandbox.cleanup();
}
},
});
test.describe("Choosing installed skills", () => {
test("installs only the skills selected in Settings", async ({ page, startSkills }) => {
const skills = await startSkills();
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo", "paseo-loop"]);
await saveSkillSelection(page);
await expectSkillsInstalled(skills, ["paseo", "paseo-loop"]);
await expectSelectedSkills(page, ["paseo", "paseo-loop"]);
});
test("all skills includes every available skill", async ({ page, startSkills }) => {
const skills = await startSkills();
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo"]);
await saveSkillSelection(page);
await openSkillSelection(page);
await chooseAllSkills(page);
await saveSkillSelection(page);
await expectSkillsInstalled(skills, skills.bundledSkills);
await expectAllSkillsSelected(page);
});
test("cancelling leaves the installed skills untouched", async ({ page, startSkills }) => {
const skills = await startSkills();
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo"]);
await saveSkillSelection(page);
await openSkillSelection(page);
await toggleSkill(page, "paseo-loop");
await cancelSkillSelection(page);
await expectSkillsInstalled(skills, ["paseo"]);
await expectSelectedSkills(page, ["paseo"]);
});
test("a failed save keeps the sheet open with an error", async ({ page, startSkills }) => {
const skills = await startSkills({ blockAgentsDir: true });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo"]);
await saveSkillSelection(page);
await expectSaveErrorKeepsSheetOpen(page);
await expectSkillsInstalled(skills, []);
});
test("an empty selection does not offer an Install action that cannot install anything", async ({
page,
startSkills,
}) => {
await startSkills({ installed: { mode: "all" }, confirmRemoval: true });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, []);
await saveSkillSelection(page);
// The remaining Install action belongs to the command-line integration.
await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible();
});
test("a selection containing only retired skills does not offer a no-op Install", async ({
page,
startSkills,
}) => {
await startSkills({ installed: { mode: "custom", skills: ["retired-skill"] } });
await gotoAppShell(page);
await openSkillsIntegrations(page);
// The remaining Install action belongs to the command-line integration.
await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible();
});
test("an empty bundled catalog does not offer a no-op Install", async ({ page, startSkills }) => {
await startSkills({ bundledSkills: [] });
await gotoAppShell(page);
await openSkillsIntegrations(page);
// The remaining Install action belongs to the command-line integration.
await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible();
});
test("cannot dismiss the sheet while Save is pending", async ({ page, startSkills }) => {
const skills = await startSkills({ holdSave: true });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo"]);
await saveSkillSelection(page);
await skills.waitForHeldSave();
await page.keyboard.press("Escape");
await expectSkillSelectionOpen(page);
skills.releaseHeldSave();
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toHaveCount(0);
});
});
test.describe("Removing an installed skill", () => {
test("delete-only drift stays in Choose skills instead of Update", async ({
page,
startSkills,
}) => {
await startSkills({
installed: { mode: "custom", skills: ["paseo"] },
placeOnDisk: {
skill: "paseo-loop",
target: "claude",
files: { "SKILL.md": "# paseo-loop\n", "notes/mine.md": "my notes" },
},
});
await gotoAppShell(page);
await openSkillsIntegrations(page);
await expect(page.getByText("Update available", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Update", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible();
});
test("warns before deleting the skill folders", async ({ page, startSkills }) => {
const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: false });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo", "paseo-advisor"]);
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillSelectionOpen(page);
await expectSkillsInstalled(skills, skills.bundledSkills);
await expectSavedSkillSelection(skills, { mode: "all" });
});
test("confirming the warning removes the skill", async ({ page, startSkills }) => {
const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: true });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo", "paseo-advisor"]);
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillsInstalled(skills, ["paseo", "paseo-advisor"]);
await expectSelectedSkills(page, ["paseo", "paseo-advisor"]);
});
test("warns for a skill installed in only one agent directory", async ({ page, startSkills }) => {
const skills = await startSkills({
installed: { mode: "all" },
keepOnlyIn: { skill: "paseo-loop", target: "claude" },
confirmRemoval: false,
});
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo", "paseo-advisor"]);
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillSelectionOpen(page);
await expectSkillPresentIn(skills, "claude", "paseo-loop");
await expectSavedSkillSelection(skills, { mode: "all" });
});
test("warns for a skill on disk that the saved selection never mentioned", async ({
page,
startSkills,
}) => {
// The committed preference excludes paseo-loop, but the directory is back —
// a manual reinstall or an external sync. Saving any edit deletes it.
const skills = await startSkills({
installed: { mode: "custom", skills: ["paseo"] },
placeOnDisk: {
skill: "paseo-loop",
target: "claude",
files: { "SKILL.md": "# paseo-loop\n", "notes/mine.md": "my notes" },
},
confirmRemoval: false,
});
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await toggleSkill(page, "paseo-advisor");
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillSelectionOpen(page);
await expectSkillFilePresent(skills, "claude", "paseo-loop", "notes/mine.md");
await expectSavedSkillSelection(skills, { mode: "custom", skills: ["paseo"] });
});
test("warns about a directory that appears after the sheet was opened", async ({
page,
startSkills,
}) => {
const skills = await startSkills({
installed: { mode: "custom", skills: ["paseo"] },
confirmRemoval: false,
});
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
// Nothing to delete when the sheet opened; the host looks again at save time.
await skills.placeSkillOnDisk("paseo-loop", "claude");
await toggleSkill(page, "paseo-advisor");
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillSelectionOpen(page);
await expectSkillPresentIn(skills, "claude", "paseo-loop");
await expectSavedSkillSelection(skills, { mode: "custom", skills: ["paseo"] });
});
test("keeps draft choices when fresher status arrives while the sheet is open", async ({
page,
startSkills,
}) => {
const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: false });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo", "paseo-advisor"]);
// Another window commits a different selection. Saving picks that up, so the
// sheet is handed a selection it did not start from while an edit is open.
await skills.commitSelectionExternally({ mode: "custom", skills: ["paseo", "paseo-loop"] }, [
"paseo-advisor",
]);
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillChoices(page, ["paseo", "paseo-advisor"]);
});
test("adding a skill saves without a warning", async ({ page, startSkills }) => {
const skills = await startSkills({
installed: { mode: "custom", skills: ["paseo"] },
confirmRemoval: false,
});
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await toggleSkill(page, "paseo-loop");
await saveSkillSelection(page);
await expectSkillsInstalled(skills, ["paseo", "paseo-loop"]);
await expectNoRemovalWarning(page);
});
});
@@ -165,19 +165,17 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 1,
minHeight: 0,
},
desktopContent: {
// The sheet's content inset — one definition for every presentation, always
// applied through <SheetContent />.
sheetContent: {
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
},
contentGrow: {
flexGrow: 1,
},
bottomSheetContent: {
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
},
bottomSheetStaticContent: {
compactStaticContent: {
flex: 1,
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
minHeight: 0,
},
bottomSheetVisibleContent: {
@@ -191,8 +189,6 @@ const styles = StyleSheet.create((theme) => ({
desktopStaticContent: {
flexShrink: 1,
minHeight: 0,
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
},
footer: {
paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
@@ -233,6 +229,20 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
return <Animated.View pointerEvents="none" style={combinedStyle} />;
}
/**
* The sheet body, indented to the sheet's content inset.
*
* The inset lives on a real `View` and never on a scroller's
* `contentContainerStyle`: that is a library prop, not the `style` prop
* Unistyles registers, so a themed inset handed to a third-party scroller such
* as `BottomSheetScrollView` silently resolves to nothing on web — which is how
* the compact sheet ended up rendering its cards flush to the screen edges. See
* docs/unistyles.md "Main Gotcha: contentContainerStyle".
*/
function SheetContent({ style, children }: { style: StyleProp<ViewStyle>; children: ReactNode }) {
return <View style={[styles.sheetContent, style]}>{children}</View>;
}
function BottomSheetVisibleContent({ children }: { children: ReactNode }) {
const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } =
useBottomSheetInternal();
@@ -372,6 +382,7 @@ export function SheetHeaderView({
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
{showCloseButton ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={t("common.actions.close")}
style={styles.closeButton}
onPress={onClose}
@@ -477,7 +488,8 @@ export interface AdaptiveModalSheetProps {
desktopMaxWidth?: number;
scrollable?: boolean;
presentation?: "push" | "replace";
contentContainerStyle?: StyleProp<ViewStyle>;
/** Layout intent for the sheet body, composed over the sheet's own content inset. */
contentStyle?: StyleProp<ViewStyle>;
/** Size compact sheet content to the live snap height instead of its largest snap point. */
sizeContentToCurrentSnapPoint?: boolean;
}
@@ -495,7 +507,7 @@ export function AdaptiveModalSheet({
desktopMaxWidth,
scrollable = true,
presentation,
contentContainerStyle,
contentStyle,
sizeContentToCurrentSnapPoint = false,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
@@ -514,28 +526,26 @@ export function AdaptiveModalSheet({
}),
[footer, insets.bottom, isMobile, theme.spacing],
);
const bottomSheetContentStyle = useMemo(
// Gorhom spreads this outer array into StyleSheet.compose, which accepts two arguments on web.
const compactContentStyle = useMemo(
() => [
styles.bottomSheetContent,
[
contentContainerStyle,
compactSafeAreaPadding.contentPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
: null,
],
],
[compactSafeAreaPadding.contentPaddingBottom, contentContainerStyle],
);
const bottomSheetStaticContentStyle = useMemo(
() => [
styles.bottomSheetStaticContent,
contentContainerStyle,
contentStyle,
compactSafeAreaPadding.contentPaddingBottom != null
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
: null,
],
[compactSafeAreaPadding.contentPaddingBottom, contentContainerStyle],
[compactSafeAreaPadding.contentPaddingBottom, contentStyle],
);
const compactStaticContentStyle = useMemo(
() => [styles.compactStaticContent, compactContentStyle],
[compactContentStyle],
);
const desktopScrollContentStyle = useMemo(
() => [styles.contentGrow, contentStyle],
[contentStyle],
);
const desktopStaticContentStyle = useMemo(
() => [styles.desktopStaticContent, contentStyle],
[contentStyle],
);
const footerStyle = useMemo(
() => [
@@ -649,14 +659,13 @@ export function AdaptiveModalSheet({
{scrollable ? (
<BottomSheetScrollView
style={sizeContentToCurrentSnapPoint ? styles.bottomSheetVisibleScroll : undefined}
contentContainerStyle={bottomSheetContentStyle}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
<SheetContent style={compactContentStyle}>{children}</SheetContent>
</BottomSheetScrollView>
) : (
<View style={bottomSheetStaticContentStyle}>{children}</View>
<SheetContent style={compactStaticContentStyle}>{children}</SheetContent>
)}
{footer ? <View style={footerStyle}>{footer}</View> : null}
</>
@@ -695,15 +704,15 @@ export function AdaptiveModalSheet({
<View style={styles.desktopScrollContainer}>
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={[styles.desktopContent, contentContainerStyle]}
contentContainerStyle={styles.contentGrow}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator
>
{children}
<SheetContent style={desktopScrollContentStyle}>{children}</SheetContent>
</ScrollView>
</View>
) : (
<View style={[styles.desktopStaticContent, contentContainerStyle]}>{children}</View>
<SheetContent style={desktopStaticContentStyle}>{children}</SheetContent>
)}
{footer ? <View style={footerStyle}>{footer}</View> : null}
</OverlayLayerProvider>
@@ -155,7 +155,7 @@ export function CompactModelSheet({
sizeContentToCurrentSnapPoint={usesBottomSheet}
footer={compactFooter}
footerContainerStyle={usesBottomSheet ? styles.compactFooterContainer : undefined}
contentContainerStyle={styles.sheetBody}
contentStyle={styles.sheetBody}
testID="agent-controls-model-sheet"
>
<View
@@ -1,10 +1,10 @@
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowUpRight, Terminal, Blocks, Check } from "lucide-react-native";
import { ArrowUpRight, Terminal, Blocks, Check, Settings2 } from "lucide-react-native";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section";
import { Button } from "@/components/ui/button";
@@ -13,8 +13,9 @@ import { confirmDialog } from "@/utils/confirm-dialog";
import {
shouldUseDesktopDaemon,
type SkillOp,
type SkillsStatus,
type SkillsSnapshot,
} from "@/desktop/daemon/desktop-daemon";
import { SkillSelectionSheet } from "@/desktop/components/skill-selection-sheet";
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
@@ -50,8 +51,14 @@ export function IntegrationsSection() {
install: installSkills,
update: updateSkills,
uninstall: uninstallSkills,
saveSelection: saveSkillSelection,
refresh: refreshSkillsStatus,
} = useSkillsStatus();
const [isChoosingSkills, setIsChoosingSkills] = useState(false);
const skillMaintenanceOps = useMemo(
() => skillsStatus?.ops.filter((op) => op.kind !== "delete") ?? [],
[skillsStatus?.ops],
);
useFocusEffect(
useCallback(() => {
@@ -74,18 +81,17 @@ export function IntegrationsSection() {
const handleUpdateSkills = useCallback(async () => {
if (isSkillsWorking) return;
const ops = skillsStatus?.ops ?? [];
const confirmed = await confirmDialog({
title: t("settings.integrations.skills.updateTitle"),
message:
ops.length > 0
? formatUpdateMessage(ops, t)
skillMaintenanceOps.length > 0
? formatUpdateMessage(skillMaintenanceOps, t)
: t("settings.integrations.skills.updateFallback"),
confirmLabel: t("settings.integrations.actions.update"),
});
if (!confirmed) return;
await updateSkills();
}, [isSkillsWorking, skillsStatus, t, updateSkills]);
}, [isSkillsWorking, skillMaintenanceOps, t, updateSkills]);
const handleUninstallSkills = useCallback(async () => {
if (isSkillsWorking) return;
@@ -99,6 +105,14 @@ export function IntegrationsSection() {
await uninstallSkills();
}, [isSkillsWorking, t, uninstallSkills]);
const handleOpenSkillSelection = useCallback(() => {
setIsChoosingSkills(true);
}, []);
const handleCloseSkillSelection = useCallback(() => {
setIsChoosingSkills(false);
}, []);
const handleOpenCliDocs = useCallback(() => {
void openExternalUrl(CLI_DOCS_URL);
}, []);
@@ -112,6 +126,11 @@ export function IntegrationsSection() {
[theme.iconSize.sm, theme.colors.foregroundMuted],
);
const chooseSkillsIcon = useMemo(
() => <Settings2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
);
const trailing = useMemo(
() => (
<View style={styles.headerLinks}>
@@ -146,7 +165,14 @@ export function IntegrationsSection() {
return null;
}
const skillsState = skillsStatus?.state ?? null;
const skillsState =
skillsStatus?.state === "drift" && skillMaintenanceOps.length === 0
? "up-to-date"
: (skillsStatus?.state ?? null);
const hasSelectedSkills =
(skillsStatus?.selection.mode === "all" && skillsStatus.available.length > 0) ||
(skillsStatus?.selection.mode === "custom" &&
skillsStatus.selection.skills.some((name) => skillsStatus.available.includes(name)));
return (
<SettingsSection title={t("settings.integrations.title")} trailing={trailing}>
@@ -193,21 +219,43 @@ export function IntegrationsSection() {
: t("settings.integrations.skills.description")}
</Text>
</View>
<SkillsActions
state={skillsState}
isWorking={isSkillsWorking}
onInstall={handleInstallSkills}
onUpdate={handleUpdateSkills}
onUninstall={handleUninstallSkills}
/>
<View style={styles.actionsRow}>
<Button
variant="ghost"
size="sm"
leftIcon={chooseSkillsIcon}
onPress={handleOpenSkillSelection}
disabled={skillsStatus === null || isSkillsWorking}
accessibilityLabel={t("settings.integrations.skills.choose")}
/>
{hasSelectedSkills ? (
<SkillsActions
state={skillsState}
isWorking={isSkillsWorking}
onInstall={handleInstallSkills}
onUpdate={handleUpdateSkills}
onUninstall={handleUninstallSkills}
/>
) : null}
</View>
</View>
</View>
{skillsStatus ? (
<SkillSelectionSheet
visible={isChoosingSkills}
available={skillsStatus.available}
selection={skillsStatus.selection}
isSaving={isSkillsWorking}
onSave={saveSkillSelection}
onClose={handleCloseSkillSelection}
/>
) : null}
</SettingsSection>
);
}
interface SkillsActionsProps {
state: SkillsStatus["state"] | null;
state: SkillsSnapshot["state"] | null;
isWorking: boolean;
onInstall: () => void;
onUpdate: () => void;
@@ -0,0 +1,288 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Check } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog";
import type { SkillSelection, SkillsSaveResult } from "@/desktop/daemon/desktop-daemon";
const ThemedCheck = withUnistyles(Check);
const checkedIconMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
interface SkillSelectionSheetProps {
visible: boolean;
available: readonly string[];
selection: SkillSelection;
isSaving: boolean;
onSave: (
selection: SkillSelection,
confirmedRemovals?: readonly string[],
) => Promise<SkillsSaveResult>;
onClose: () => void;
}
function isSkillSelected(draft: SkillSelection, name: string): boolean {
return draft.mode === "all" || draft.skills.includes(name);
}
/**
* Draft form state only. The saved selection, the convergence across agent skill
* directories, and the resulting status all belong to the desktop skills module.
*/
export function SkillSelectionSheet({
visible,
available,
selection,
isSaving,
onSave,
onClose,
}: SkillSelectionSheetProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState<SkillSelection>(selection);
const [saveError, setSaveError] = useState<string | null>(null);
const wasVisible = useRef(visible);
// Seeded when the sheet opens, not whenever a background status refresh hands
// back a new selection object — that would throw away edits in progress.
useEffect(() => {
const opening = visible && !wasVisible.current;
wasVisible.current = visible;
if (!opening) return;
setDraft(selection);
setSaveError(null);
}, [selection, visible]);
const handleToggleAll = useCallback(
(nextAll: boolean) => {
// Leaving `all` keeps every currently installed skill checked, so turning
// the switch off is a starting point rather than a reset.
setDraft(nextAll ? { mode: "all" } : { mode: "custom", skills: [...available] });
},
[available],
);
const handleToggleSkill = useCallback((name: string) => {
setDraft((current) => {
if (current.mode === "all") return current;
const skills = current.skills.includes(name)
? current.skills.filter((skill) => skill !== name)
: [...current.skills, name].sort();
return { mode: "custom", skills };
});
}, []);
const handleSave = useCallback(() => {
void (async () => {
setSaveError(null);
try {
// The host refuses a save that would delete directories it was not told
// about, and answers with the names it found when it looked. Asking it
// is the only way the question and the deletion cannot drift apart.
const attempt = await onSave(draft);
if (attempt.confirmationRequired) {
const confirmed = await confirmDialog({
title: t("settings.integrations.skills.removeTitle"),
message: t("settings.integrations.skills.removeMessage", {
skills: attempt.confirmationRequired.removals.join(", "),
}),
confirmLabel: t("settings.integrations.actions.remove"),
destructive: true,
});
if (!confirmed) return;
const retry = await onSave(draft, attempt.confirmationRequired.removals);
// Something else changed the directories again while the dialog was
// up. Ask about the new list rather than deleting it unannounced.
if (retry.confirmationRequired) {
setSaveError(t("settings.integrations.skills.saveFailed"));
return;
}
}
onClose();
} catch {
setSaveError(t("settings.integrations.skills.saveFailed"));
}
})();
}, [draft, onClose, onSave, t]);
const handleDismiss = useCallback(() => {
if (isSaving) return;
onClose();
}, [isSaving, onClose]);
const header = useMemo<SheetHeader>(
() => ({ title: t("settings.integrations.skills.choose") }),
[t],
);
const footer = useMemo(
() => (
<View style={styles.footer}>
<Button
style={styles.footerButton}
variant="secondary"
onPress={handleDismiss}
disabled={isSaving}
>
{t("common.actions.cancel")}
</Button>
<Button
style={styles.footerButton}
variant="default"
onPress={handleSave}
loading={isSaving}
testID="skill-selection-save"
>
{isSaving
? t("settings.integrations.actions.saving")
: t("settings.integrations.actions.save")}
</Button>
</View>
),
[handleDismiss, handleSave, isSaving, t],
);
const allSkills = draft.mode === "all";
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={handleDismiss}
footer={footer}
testID="skill-selection-sheet"
>
<View style={settingsStyles.card} testID="skill-selection-all-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{t("settings.integrations.skills.chooseAll")}
</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.integrations.skills.chooseAllHint")}
</Text>
</View>
<Switch
value={allSkills}
onValueChange={handleToggleAll}
disabled={isSaving}
accessibilityLabel={t("settings.integrations.skills.chooseAll")}
testID="skill-selection-all"
/>
</View>
</View>
{/* The sheet body already spaces its blocks apart, so the section owns
only the gap between its label and the list the label names. */}
<SettingsSection title={t("settings.integrations.skills.chooseList")} flush>
<View style={settingsStyles.card} testID="skill-selection-list-card">
{available.length === 0 ? (
<View style={settingsStyles.row}>
<Text style={settingsStyles.rowHint}>
{t("settings.integrations.skills.chooseEmpty")}
</Text>
</View>
) : (
available.map((name, index) => (
<SkillCheckboxRow
key={name}
name={name}
checked={isSkillSelected(draft, name)}
disabled={allSkills || isSaving}
withBorder={index > 0}
onToggle={handleToggleSkill}
/>
))
)}
</View>
</SettingsSection>
{saveError ? <Text style={settingsStyles.rowError}>{saveError}</Text> : null}
</AdaptiveModalSheet>
);
}
interface SkillCheckboxRowProps {
name: string;
checked: boolean;
disabled: boolean;
withBorder: boolean;
onToggle: (name: string) => void;
}
function SkillCheckboxRow({
name,
checked,
disabled,
withBorder,
onToggle,
}: SkillCheckboxRowProps) {
const handlePress = useCallback(() => onToggle(name), [name, onToggle]);
const accessibilityState = useMemo(() => ({ checked, disabled }), [checked, disabled]);
const rowStyle = useMemo(
() => [
settingsStyles.row,
withBorder ? settingsStyles.rowBorder : null,
styles.checkboxRow,
disabled ? styles.disabledRow : null,
],
[disabled, withBorder],
);
const boxStyle = useMemo(
() => [styles.checkbox, checked ? styles.checkboxChecked : null],
[checked],
);
return (
<Pressable
style={rowStyle}
onPress={handlePress}
disabled={disabled}
accessibilityRole="checkbox"
accessibilityLabel={name}
accessibilityState={accessibilityState}
aria-checked={checked}
>
<View style={boxStyle}>
{checked ? <ThemedCheck size={14} uniProps={checkedIconMapping} /> : null}
</View>
<Text style={settingsStyles.rowTitle}>{name}</Text>
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
footer: {
flex: 1,
flexDirection: "row",
gap: theme.spacing[3],
},
footerButton: {
flex: 1,
},
checkboxRow: {
justifyContent: "flex-start",
gap: theme.spacing[3],
},
disabledRow: {
opacity: theme.opacity[50],
},
checkbox: {
width: 22,
height: 22,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
checkboxChecked: {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
},
}));
@@ -1,5 +1,12 @@
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
import {
parseSkillsSaveResult,
parseSkillsSnapshot,
type SkillSelection,
type SkillsSaveResult,
type SkillsSnapshot,
} from "@/desktop/daemon/skills-snapshot";
export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
export type DesktopDaemonStopReason =
@@ -246,67 +253,38 @@ export async function installCli(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
}
export type SkillsState = "not-installed" | "up-to-date" | "drift";
export type {
SkillOp,
SkillSelection,
SkillsSaveResult,
SkillsSnapshot,
SkillsState,
} from "@/desktop/daemon/skills-snapshot";
export type SkillOp =
| { kind: "add"; name: string }
| { kind: "update"; name: string }
| { kind: "delete"; name: string };
export interface SkillsStatus {
state: SkillsState;
ops: SkillOp[];
export async function getSkillsSnapshot(): Promise<SkillsSnapshot> {
return parseSkillsSnapshot(await invokeDesktopCommand("get_skills_status"));
}
function parseSkillsState(value: unknown): SkillsState {
switch (value) {
case "not-installed":
case "up-to-date":
case "drift":
return value;
default:
throw new Error(`Unexpected skills status state: ${String(value)}`);
}
export async function installSkills(): Promise<SkillsSnapshot> {
return parseSkillsSnapshot(await invokeDesktopCommand("install_skills"));
}
function parseSkillOp(raw: unknown): SkillOp {
if (!isRecord(raw)) {
throw new Error("Unexpected skill op response.");
}
const name = toStringOrNull(raw.name);
if (!name) throw new Error("Skill op missing name.");
switch (raw.kind) {
case "add":
return { kind: "add", name };
case "update":
return { kind: "update", name };
case "delete":
return { kind: "delete", name };
default:
throw new Error(`Unexpected skill op kind: ${String(raw.kind)}`);
}
export async function updateSkills(): Promise<SkillsSnapshot> {
return parseSkillsSnapshot(await invokeDesktopCommand("update_skills"));
}
function parseSkillsStatus(raw: unknown): SkillsStatus {
if (!isRecord(raw)) {
throw new Error("Unexpected skills status response.");
}
const ops = Array.isArray(raw.ops) ? raw.ops.map(parseSkillOp) : [];
return { state: parseSkillsState(raw.state), ops };
export async function uninstallSkills(): Promise<SkillsSnapshot> {
return parseSkillsSnapshot(await invokeDesktopCommand("uninstall_skills"));
}
export async function getSkillsStatus(): Promise<SkillsStatus> {
return parseSkillsStatus(await invokeDesktopCommand("get_skills_status"));
}
export async function installSkills(): Promise<SkillsStatus> {
return parseSkillsStatus(await invokeDesktopCommand("install_skills"));
}
export async function updateSkills(): Promise<SkillsStatus> {
return parseSkillsStatus(await invokeDesktopCommand("update_skills"));
}
export async function uninstallSkills(): Promise<SkillsStatus> {
return parseSkillsStatus(await invokeDesktopCommand("uninstall_skills"));
export async function saveSkillsSelection(
selection: SkillSelection,
confirmedRemovals: readonly string[] = [],
): Promise<SkillsSaveResult> {
return parseSkillsSaveResult(
await invokeDesktopCommand("save_skills_selection", {
...selection,
confirmedRemovals: [...confirmedRemovals],
}),
);
}
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import { parseSkillsSaveResult, parseSkillsSnapshot } from "./skills-snapshot";
describe("parseSkillsSnapshot", () => {
it("parses a full snapshot from the desktop command surface", () => {
expect(
parseSkillsSnapshot({
state: "drift",
ops: [
{ kind: "add", name: "paseo-loop" },
{ kind: "delete", name: "paseo-chat" },
],
available: ["paseo", "paseo-loop"],
installed: ["paseo"],
selection: { mode: "custom", skills: ["paseo", "paseo-loop"] },
}),
).toEqual({
state: "drift",
ops: [
{ kind: "add", name: "paseo-loop" },
{ kind: "delete", name: "paseo-chat" },
],
available: ["paseo", "paseo-loop"],
installed: ["paseo"],
selection: { mode: "custom", skills: ["paseo", "paseo-loop"] },
});
});
it("parses the installed skills the host reports", () => {
expect(
parseSkillsSnapshot({
state: "drift",
ops: [{ kind: "add", name: "paseo-loop" }],
available: ["paseo", "paseo-loop"],
installed: ["paseo-loop", 7],
selection: { mode: "all" },
}).installed,
).toEqual(["paseo-loop"]);
});
it("assumes the saved selection is installed when the host does not report it", () => {
// An older host has no `installed` field. Assuming the selection is on disk
// keeps the destructive confirmation firing rather than silently skipping it.
expect(
parseSkillsSnapshot({
state: "up-to-date",
ops: [],
available: ["paseo", "paseo-advisor", "paseo-loop"],
selection: { mode: "custom", skills: ["paseo", "paseo-loop"] },
}).installed,
).toEqual(["paseo", "paseo-loop"]);
});
it("assumes every bundled skill is installed for an all selection from an older host", () => {
expect(
parseSkillsSnapshot({
state: "up-to-date",
ops: [],
available: ["paseo", "paseo-loop"],
}).installed,
).toEqual(["paseo", "paseo-loop"]);
});
it("reads a snapshot with no saved selection as all skills", () => {
expect(parseSkillsSnapshot({ state: "up-to-date", ops: [], available: ["paseo"] })).toEqual({
state: "up-to-date",
ops: [],
available: ["paseo"],
installed: ["paseo"],
selection: { mode: "all" },
});
});
it("drops catalog and selection entries that are not skill names", () => {
expect(
parseSkillsSnapshot({
state: "up-to-date",
ops: [],
available: ["paseo", 7, null, "paseo-loop"],
selection: { mode: "custom", skills: ["paseo", 7] },
}),
).toEqual({
state: "up-to-date",
ops: [],
available: ["paseo", "paseo-loop"],
installed: ["paseo"],
selection: { mode: "custom", skills: ["paseo"] },
});
});
it("reads the removals the host wants confirmed", () => {
expect(
parseSkillsSaveResult({
state: "up-to-date",
ops: [],
available: ["paseo", "paseo-loop"],
installed: ["paseo", "paseo-loop"],
selection: { mode: "all" },
confirmationRequired: { removals: ["paseo-loop", 7] },
}).confirmationRequired,
).toEqual({ removals: ["paseo-loop"] });
});
it("treats a save with no confirmation request as applied", () => {
expect(
parseSkillsSaveResult({
state: "up-to-date",
ops: [],
available: ["paseo"],
installed: ["paseo"],
selection: { mode: "all" },
}).confirmationRequired,
).toBeNull();
});
it("rejects a response that is not an object", () => {
expect(() => parseSkillsSnapshot("nope")).toThrow("Unexpected skills status response.");
});
it("rejects an unknown install state", () => {
expect(() => parseSkillsSnapshot({ state: "half-installed", ops: [] })).toThrow(
"Unexpected skills status state: half-installed",
);
});
it("rejects an unknown pending operation kind", () => {
expect(() =>
parseSkillsSnapshot({ state: "drift", ops: [{ kind: "relocate", name: "paseo" }] }),
).toThrow("Unexpected skill op kind: relocate");
});
});
@@ -0,0 +1,113 @@
export type SkillsState = "not-installed" | "up-to-date" | "drift";
export type SkillOp =
| { kind: "add"; name: string }
| { kind: "update"; name: string }
| { kind: "delete"; name: string };
/** What the user asked to have installed. `all` follows the bundle as it grows. */
export type SkillSelection = { mode: "all" } | { mode: "custom"; skills: string[] };
export interface SkillsSnapshot {
state: SkillsState;
ops: SkillOp[];
/** Every skill the host currently bundles, sorted. The selectable catalog. */
available: string[];
/**
* Skills with a directory in at least one agent home. An `add` op only means
* "missing from at least one target", so it cannot stand in for this.
*/
installed: string[];
selection: SkillSelection;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseSkillsState(value: unknown): SkillsState {
switch (value) {
case "not-installed":
case "up-to-date":
case "drift":
return value;
default:
throw new Error(`Unexpected skills status state: ${String(value)}`);
}
}
function parseSkillOp(raw: unknown): SkillOp {
if (!isRecord(raw)) {
throw new Error("Unexpected skill op response.");
}
const name = typeof raw.name === "string" ? raw.name.trim() : "";
if (!name) throw new Error("Skill op missing name.");
switch (raw.kind) {
case "add":
return { kind: "add", name };
case "update":
return { kind: "update", name };
case "delete":
return { kind: "delete", name };
default:
throw new Error(`Unexpected skill op kind: ${String(raw.kind)}`);
}
}
function parseSkillNames(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((entry): entry is string => typeof entry === "string");
}
function parseSkillSelection(value: unknown): SkillSelection {
// A host that predates the picker sends no selection; it manages every
// bundled skill, which is exactly what `all` means.
if (!isRecord(value)) return { mode: "all" };
if (value.mode === "custom") return { mode: "custom", skills: parseSkillNames(value.skills) };
return { mode: "all" };
}
function selectedSkills(selection: SkillSelection, available: readonly string[]): string[] {
if (selection.mode === "all") return [...available];
const chosen = new Set(selection.skills);
return available.filter((name) => chosen.has(name));
}
export interface SkillsSaveResult extends SkillsSnapshot {
/**
* Non-null when the host refused the save because these directories would be
* deleted. Confirm the names with the user and retry with them.
*/
confirmationRequired: { removals: string[] } | null;
}
export function parseSkillsSaveResult(raw: unknown): SkillsSaveResult {
const snapshot = parseSkillsSnapshot(raw);
const required =
isRecord(raw) && isRecord(raw.confirmationRequired)
? parseSkillNames(raw.confirmationRequired.removals)
: [];
return {
...snapshot,
confirmationRequired: required.length > 0 ? { removals: required } : null,
};
}
export function parseSkillsSnapshot(raw: unknown): SkillsSnapshot {
if (!isRecord(raw)) {
throw new Error("Unexpected skills status response.");
}
const available = parseSkillNames(raw.available);
const selection = parseSkillSelection(raw.selection);
return {
state: parseSkillsState(raw.state),
ops: Array.isArray(raw.ops) ? raw.ops.map(parseSkillOp) : [],
available,
// A host that predates this field cannot say what is on disk. Assume the
// saved selection is, so removing a skill still asks before deleting it.
installed: Array.isArray(raw.installed)
? parseSkillNames(raw.installed)
: selectedSkills(selection, available),
selection,
};
}
@@ -17,10 +17,11 @@ const toast = vi.hoisted(() => ({
const desktopDaemon = vi.hoisted(() => ({
getCliInstallStatus: vi.fn(),
installCli: vi.fn(),
getSkillsStatus: vi.fn(),
getSkillsSnapshot: vi.fn(),
installSkills: vi.fn(),
updateSkills: vi.fn(),
uninstallSkills: vi.fn(),
saveSkillsSelection: vi.fn(),
shouldUseDesktopDaemon: vi.fn(() => true),
}));
@@ -127,7 +128,7 @@ describe("useSkillsStatus", () => {
});
it("loads the current skills status", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({
desktopDaemon.getSkillsSnapshot.mockResolvedValue({
state: "up-to-date",
ops: [],
});
@@ -142,7 +143,7 @@ describe("useSkillsStatus", () => {
});
it("install transitions a not-installed status to up-to-date and reflects the response directly", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({
desktopDaemon.getSkillsSnapshot.mockResolvedValue({
state: "not-installed",
ops: [{ kind: "add", name: "paseo" }],
});
@@ -165,7 +166,7 @@ describe("useSkillsStatus", () => {
});
it("update transitions drift to up-to-date", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({
desktopDaemon.getSkillsSnapshot.mockResolvedValue({
state: "drift",
ops: [{ kind: "update", name: "paseo" }],
});
@@ -188,7 +189,7 @@ describe("useSkillsStatus", () => {
});
it("uninstall transitions up-to-date back to not-installed", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({ state: "up-to-date", ops: [] });
desktopDaemon.getSkillsSnapshot.mockResolvedValue({ state: "up-to-date", ops: [] });
desktopDaemon.uninstallSkills.mockResolvedValue({
state: "not-installed",
ops: [{ kind: "add", name: "paseo" }],
@@ -214,7 +215,7 @@ describe("useSkillsStatus", () => {
});
it("isWorking flips while a mutation is in flight", async () => {
desktopDaemon.getSkillsStatus.mockResolvedValue({
desktopDaemon.getSkillsSnapshot.mockResolvedValue({
state: "not-installed",
ops: [{ kind: "add", name: "paseo" }],
});
@@ -256,7 +257,7 @@ describe("useSkillsStatus", () => {
it("toasts and exposes errors when install fails", async () => {
const error = new Error("Missing IPC handler");
desktopDaemon.getSkillsStatus.mockResolvedValue({
desktopDaemon.getSkillsSnapshot.mockResolvedValue({
state: "not-installed",
ops: [{ kind: "add", name: "paseo" }],
});
@@ -3,12 +3,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
getCliInstallStatus,
getSkillsStatus,
getSkillsSnapshot,
installCli,
installSkills,
saveSkillsSelection,
shouldUseDesktopDaemon,
type InstallStatus,
type SkillsStatus,
type SkillSelection,
type SkillsSaveResult,
type SkillsSnapshot,
uninstallSkills,
updateSkills,
} from "@/desktop/daemon/desktop-daemon";
@@ -79,7 +82,7 @@ export function useCliInstall(): DesktopInstallHookResult {
}
export interface SkillsStatusHookResult {
status: SkillsStatus | null;
status: SkillsSnapshot | null;
isLoading: boolean;
isWorking: boolean;
error: Error | null;
@@ -87,6 +90,10 @@ export interface SkillsStatusHookResult {
install: () => Promise<void>;
update: () => Promise<void>;
uninstall: () => Promise<void>;
saveSelection: (
selection: SkillSelection,
confirmedRemovals?: readonly string[],
) => Promise<SkillsSaveResult>;
}
export function useSkillsStatus(): SkillsStatusHookResult {
@@ -95,9 +102,9 @@ export function useSkillsStatus(): SkillsStatusHookResult {
const reportError = useDesktopIpcErrorReporter();
const enabled = shouldUseDesktopDaemon();
const statusQuery = useQuery<SkillsStatus, Error>({
const statusQuery = useQuery<SkillsSnapshot, Error>({
queryKey: SKILLS_STATUS_QUERY_KEY,
queryFn: getSkillsStatus,
queryFn: getSkillsSnapshot,
enabled,
retry: false,
});
@@ -109,13 +116,13 @@ export function useSkillsStatus(): SkillsStatusHookResult {
});
const setStatus = useCallback(
(next: SkillsStatus) => {
queryClient.setQueryData<SkillsStatus>(SKILLS_STATUS_QUERY_KEY, next);
(next: SkillsSnapshot) => {
queryClient.setQueryData<SkillsSnapshot>(SKILLS_STATUS_QUERY_KEY, next);
},
[queryClient],
);
const installMutation = useMutation<SkillsStatus, Error>({
const installMutation = useMutation<SkillsSnapshot, Error>({
mutationFn: installSkills,
onError: (error) => {
reportError({
@@ -127,7 +134,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
onSuccess: setStatus,
});
const updateMutation = useMutation<SkillsStatus, Error>({
const updateMutation = useMutation<SkillsSnapshot, Error>({
mutationFn: updateSkills,
onError: (error) => {
reportError({
@@ -139,7 +146,7 @@ export function useSkillsStatus(): SkillsStatusHookResult {
onSuccess: setStatus,
});
const uninstallMutation = useMutation<SkillsStatus, Error>({
const uninstallMutation = useMutation<SkillsSnapshot, Error>({
mutationFn: uninstallSkills,
onError: (error) => {
reportError({
@@ -151,8 +158,28 @@ export function useSkillsStatus(): SkillsStatusHookResult {
onSuccess: setStatus,
});
const saveSelectionMutation = useMutation<
SkillsSaveResult,
Error,
{ selection: SkillSelection; confirmedRemovals: readonly string[] }
>({
mutationFn: ({ selection, confirmedRemovals }) =>
saveSkillsSelection(selection, confirmedRemovals),
onError: (error) => {
reportError({
error,
message: t("desktop.integrations.skills.saveSelectionFailed"),
logLabel: "[Integrations] Failed to save skills selection",
});
},
onSuccess: setStatus,
});
const isWorking =
installMutation.isPending || updateMutation.isPending || uninstallMutation.isPending;
installMutation.isPending ||
updateMutation.isPending ||
uninstallMutation.isPending ||
saveSelectionMutation.isPending;
const refresh = useCallback(async () => {
await refetch();
@@ -170,6 +197,12 @@ export function useSkillsStatus(): SkillsStatusHookResult {
await uninstallMutation.mutateAsync().catch(() => undefined);
}, [uninstallMutation]);
const saveSelection = useCallback(
async (selection: SkillSelection, confirmedRemovals: readonly string[] = []) =>
saveSelectionMutation.mutateAsync({ selection, confirmedRemovals }),
[saveSelectionMutation],
);
return {
status: status ?? null,
isLoading,
@@ -179,10 +212,12 @@ export function useSkillsStatus(): SkillsStatusHookResult {
installMutation.error ??
updateMutation.error ??
uninstallMutation.error ??
saveSelectionMutation.error ??
null,
refresh,
install,
update,
uninstall,
saveSelection,
};
}
+13
View File
@@ -1160,6 +1160,7 @@ export const ar: TranslationResources = {
installFailed: "غير قادر على تثبيت مهارات التنسيق.",
updateFailed: "غير قادر على تحديث مهارات التنسيق.",
uninstallFailed: "غير قادر على إلغاء تثبيت مهارات التنسيق.",
saveSelectionFailed: "غير قادر على حفظ اختيار مهارات التنسيق.",
},
},
},
@@ -1863,6 +1864,15 @@ export const ar: TranslationResources = {
updateFallback: "مزامنة المهارات المجمعة لجهازك.",
uninstallTitle: "إلغاء تثبيت مهارات Paseo ؟",
uninstallMessage: "يزيل جميع مهارات تنسيق Paseo من ~/.agents ، ~/.claude ، ~/.codex.",
choose: "اختيار المهارات",
chooseAll: "كل المهارات",
chooseAllHint: "أبقِ جميع المهارات المرفقة مثبتة، بما فيها ما يُضاف لاحقًا.",
chooseList: "المهارات المرفقة",
chooseEmpty: "لا تتضمن هذه النسخة أي مهارات.",
removeTitle: "إزالة المهارات غير المحددة؟",
removeMessage:
"سيتم حذف {{skills}} من ~/.agents و~/.claude و~/.codex. كما يُحذف كل ما أضفته داخل مجلدات المهارات تلك.",
saveFailed: "تعذر حفظ اختيار المهارات.",
},
actions: {
install: "ثَبَّتَ",
@@ -1870,7 +1880,10 @@ export const ar: TranslationResources = {
installed: "تم التثبيت",
update: "تحديث",
working: "عمل...",
remove: "إزالة",
uninstall: "إلغاء التثبيت",
save: "حفظ",
saving: "جارٍ الحفظ...",
},
operations: {
add: "أضف مهارة",
+13
View File
@@ -1170,6 +1170,7 @@ export const en = {
installFailed: "Unable to install orchestration skills.",
updateFailed: "Unable to update orchestration skills.",
uninstallFailed: "Unable to uninstall orchestration skills.",
saveSelectionFailed: "Unable to save the orchestration skills selection.",
},
},
},
@@ -1874,6 +1875,15 @@ export const en = {
uninstallTitle: "Uninstall Paseo skills?",
uninstallMessage:
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.",
choose: "Choose skills",
chooseAll: "All skills",
chooseAllHint: "Keep every bundled skill installed, including ones added later.",
chooseList: "Bundled skills",
chooseEmpty: "This build bundles no skills.",
removeTitle: "Remove deselected skills?",
removeMessage:
"{{skills}} will be deleted from ~/.agents, ~/.claude, and ~/.codex. Anything you added inside those skill folders is deleted too.",
saveFailed: "Could not save your skill selection.",
},
actions: {
install: "Install",
@@ -1881,7 +1891,10 @@ export const en = {
installed: "Installed",
update: "Update",
working: "Working...",
remove: "Remove",
uninstall: "Uninstall",
save: "Save",
saving: "Saving...",
},
operations: {
add: "Add skill",
+14
View File
@@ -1200,6 +1200,7 @@ export const es: TranslationResources = {
installFailed: "No se pueden instalar habilidades de orquestación.",
updateFailed: "No se pueden actualizar las habilidades de orquestación.",
uninstallFailed: "No se pueden desinstalar las habilidades de orquestación.",
saveSelectionFailed: "No se puede guardar la selección de habilidades de orquestación.",
},
},
},
@@ -1912,6 +1913,16 @@ export const es: TranslationResources = {
uninstallTitle: "¿Desinstalar las habilidadesPaseo?",
uninstallMessage:
"Elimina todas las habilidades de orquestaciónPaseode ~/.agents, ~/.claude, ~/.codex.",
choose: "Elegir habilidades",
chooseAll: "Todas las habilidades",
chooseAllHint:
"Mantén instaladas todas las habilidades incluidas, también las que se agreguen después.",
chooseList: "Habilidades incluidas",
chooseEmpty: "Esta versión no incluye habilidades.",
removeTitle: "¿Eliminar las habilidades deseleccionadas?",
removeMessage:
"{{skills}} se eliminarán de ~/.agents, ~/.claude y ~/.codex. También se elimina todo lo que hayas añadido dentro de esas carpetas de habilidades.",
saveFailed: "No se pudo guardar tu selección de habilidades.",
},
actions: {
install: "Instalar",
@@ -1919,7 +1930,10 @@ export const es: TranslationResources = {
installed: "Instalado",
update: "Actualizar",
working: "Laboral...",
remove: "Eliminar",
uninstall: "Desinstalar",
save: "Guardar",
saving: "Guardando...",
},
operations: {
add: "Agregar habilidad",
+15
View File
@@ -1202,6 +1202,8 @@ export const fr: TranslationResources = {
installFailed: "Impossible d'installer les compétences d'orchestration.",
updateFailed: "Impossible de mettre à jour les compétences d'orchestration.",
uninstallFailed: "Impossible de désinstaller les compétences d'orchestration.",
saveSelectionFailed:
"Impossible d'enregistrer la sélection des compétences d'orchestration.",
},
},
},
@@ -1915,6 +1917,16 @@ export const fr: TranslationResources = {
uninstallTitle: "Désinstaller les compétencesPaseo?",
uninstallMessage:
"Supprime toutes les compétences d'orchestrationPaseode ~/.agents, ~/.claude, ~/.codex.",
choose: "Choisir les compétences",
chooseAll: "Toutes les compétences",
chooseAllHint:
"Gardez installées toutes les compétences fournies, y compris celles ajoutées plus tard.",
chooseList: "Compétences fournies",
chooseEmpty: "Cette version ne fournit aucune compétence.",
removeTitle: "Supprimer les compétences décochées ?",
removeMessage:
"{{skills}} seront supprimées de ~/.agents, ~/.claude et ~/.codex. Tout ce que vous avez ajouté dans ces dossiers de compétences est également supprimé.",
saveFailed: "Impossible d'enregistrer votre sélection de compétences.",
},
actions: {
install: "Installer",
@@ -1922,7 +1934,10 @@ export const fr: TranslationResources = {
installed: "Installé",
update: "Mise à jour",
working: "Fonctionnement...",
remove: "Supprimer",
uninstall: "Désinstaller",
save: "Enregistrer",
saving: "Enregistrement...",
},
operations: {
add: "Ajouter une compétence",
+14
View File
@@ -1173,6 +1173,7 @@ export const ja: TranslationResources = {
installFailed: "オーケストレーションスキルをインストールできません。",
updateFailed: "オーケストレーションスキルを更新できません。",
uninstallFailed: "オーケストレーションスキルをアンインストールできません。",
saveSelectionFailed: "オーケストレーションスキルの選択を保存できません。",
},
},
},
@@ -1881,6 +1882,16 @@ export const ja: TranslationResources = {
uninstallTitle: "Paseoスキルをアンインストールしますか?",
uninstallMessage:
"~/.agents、~/.claude、~/.codexからすべてのPaseoオーケストレーションスキルを削除します。",
choose: "スキルを選択",
chooseAll: "すべてのスキル",
chooseAllHint:
"後から追加されるものも含め、バンドルされたスキルをすべてインストールしたままにします。",
chooseList: "バンドルされたスキル",
chooseEmpty: "このビルドにはスキルが含まれていません。",
removeTitle: "選択を外したスキルを削除しますか?",
removeMessage:
"{{skills}} を ~/.agents、~/.claude、~/.codex から削除します。それらのスキルフォルダー内に追加したものもすべて削除されます。",
saveFailed: "スキルの選択を保存できませんでした。",
},
actions: {
install: "インストール",
@@ -1888,7 +1899,10 @@ export const ja: TranslationResources = {
installed: "インストール済み",
update: "更新",
working: "処理中...",
remove: "削除",
uninstall: "アンインストール",
save: "保存",
saving: "保存中...",
},
operations: {
add: "スキルを追加",
+14
View File
@@ -1186,6 +1186,7 @@ export const ptBR: TranslationResources = {
installFailed: "Não foi possível instalar as skills de orquestração.",
updateFailed: "Não foi possível atualizar as skills de orquestração.",
uninstallFailed: "Não foi possível desinstalar as skills de orquestração.",
saveSelectionFailed: "Não foi possível salvar a seleção de skills de orquestração.",
},
},
},
@@ -1896,6 +1897,16 @@ export const ptBR: TranslationResources = {
uninstallTitle: "Desinstalar Paseo skills?",
uninstallMessage:
"Remove todas as skills de orquestração do Paseo de ~/.agents, ~/.claude e ~/.codex.",
choose: "Escolher skills",
chooseAll: "Todas as skills",
chooseAllHint:
"Mantenha instaladas todas as skills incluídas, inclusive as adicionadas depois.",
chooseList: "Skills incluídas",
chooseEmpty: "Esta versão não inclui skills.",
removeTitle: "Remover as skills desmarcadas?",
removeMessage:
"{{skills}} serão excluídas de ~/.agents, ~/.claude e ~/.codex. Tudo o que você adicionou dentro dessas pastas de skills também é excluído.",
saveFailed: "Não foi possível salvar sua seleção de skills.",
},
actions: {
install: "Instalar",
@@ -1903,7 +1914,10 @@ export const ptBR: TranslationResources = {
installed: "Instalado",
update: "Atualizar",
working: "Trabalhando...",
remove: "Remover",
uninstall: "Desinstalar",
save: "Salvar",
saving: "Salvando...",
},
operations: {
add: "Adicionar skill",
+13
View File
@@ -1189,6 +1189,7 @@ export const ru: TranslationResources = {
installFailed: "Невозможно установить навыки оркестровки.",
updateFailed: "Невозможно обновить навыки оркестровки.",
uninstallFailed: "Невозможно удалить навыки оркестровки.",
saveSelectionFailed: "Не удалось сохранить выбор навыков оркестровки.",
},
},
},
@@ -1901,6 +1902,15 @@ export const ru: TranslationResources = {
updateFallback: "Синхронизируйте связанные навыки с вашим компьютером.",
uninstallTitle: "Удалить навыки Paseo?",
uninstallMessage: "Удаляет все навыки оркестровки Paseo из ~/.agents, ~/.claude, ~/.codex.",
choose: "Выбрать навыки",
chooseAll: "Все навыки",
chooseAllHint: "Держите установленными все входящие навыки, включая добавленные позже.",
chooseList: "Входящие навыки",
chooseEmpty: "В этой сборке нет навыков.",
removeTitle: "Удалить снятые навыки?",
removeMessage:
"{{skills}} будут удалены из ~/.agents, ~/.claude и ~/.codex. Всё, что вы добавили внутри этих папок навыков, тоже удаляется.",
saveFailed: "Не удалось сохранить выбор навыков.",
},
actions: {
install: "Установить",
@@ -1908,7 +1918,10 @@ export const ru: TranslationResources = {
installed: "Установлено",
update: "Обновлять",
working: "Работающий...",
remove: "Удалить",
uninstall: "Удалить",
save: "Сохранить",
saving: "Сохранение...",
},
operations: {
add: "Добавить навык",
+13
View File
@@ -1144,6 +1144,7 @@ export const zhCN: TranslationResources = {
installFailed: "无法安装编排 skills。",
updateFailed: "无法更新编排 skills。",
uninstallFailed: "无法卸载编排 skills。",
saveSelectionFailed: "无法保存编排 skills 的选择。",
},
},
},
@@ -1842,6 +1843,15 @@ export const zhCN: TranslationResources = {
updateFallback: "将内置 skills 同步到你的机器。",
uninstallTitle: "卸载 Paseo skills?",
uninstallMessage: "会从 ~/.agents、~/.claude、~/.codex 移除所有 Paseo 编排 skills。",
choose: "选择 skills",
chooseAll: "全部 skills",
chooseAllHint: "保持安装所有内置 skills,包括以后新增的。",
chooseList: "内置 skills",
chooseEmpty: "此版本未内置 skills。",
removeTitle: "移除取消勾选的 skills?",
removeMessage:
"将从 ~/.agents、~/.claude、~/.codex 中删除 {{skills}}。你在这些 skill 文件夹里添加的内容也会一并删除。",
saveFailed: "无法保存你的 skills 选择。",
},
actions: {
install: "安装",
@@ -1849,7 +1859,10 @@ export const zhCN: TranslationResources = {
installed: "已安装",
update: "更新",
working: "处理中...",
remove: "移除",
uninstall: "卸载",
save: "保存",
saving: "正在保存...",
},
operations: {
add: "添加 skill",
-7
View File
@@ -11,13 +11,6 @@ export const settingsStyles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
sectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
sectionHeaderTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,