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:
co-authored by
Claude Opus 5
parent
f3fee73976
commit
91da0bac4d
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user