diff --git a/.changeset/desktop-update-restart.md b/.changeset/desktop-update-restart.md new file mode 100644 index 00000000..f57ec22b --- /dev/null +++ b/.changeset/desktop-update-restart.md @@ -0,0 +1,5 @@ +--- +"@selftune/desktop": patch +--- + +Allow macOS updater-driven window closure before the normal shutdown event so Restart Now does not hide the app and leave the update waiting. diff --git a/.changeset/local-skill-search.md b/.changeset/local-skill-search.md new file mode 100644 index 00000000..681d497b --- /dev/null +++ b/.changeset/local-skill-search.md @@ -0,0 +1,5 @@ +--- +"@selftune/desktop": patch +--- + +Add read-only local BM25 skill search through `selftune skills search`, including inactive Library packages and collection metadata. diff --git a/.changeset/sidebar-desktop-update.md b/.changeset/sidebar-desktop-update.md new file mode 100644 index 00000000..2075d865 --- /dev/null +++ b/.changeset/sidebar-desktop-update.md @@ -0,0 +1,5 @@ +--- +"@selftune/desktop": patch +--- + +Show native update progress and a download icon that reveals Update on hover or keyboard focus in the sidebar footer. diff --git a/.changeset/temporary-skill-receipts.md b/.changeset/temporary-skill-receipts.md new file mode 100644 index 00000000..78260502 --- /dev/null +++ b/.changeset/temporary-skill-receipts.md @@ -0,0 +1,7 @@ +--- +"@selftune/desktop": patch +--- + +Support task-owned temporary skill selections with receipt-backed cleanup, overlapping-task guards, and a read-only cleanup preview. + +Expose `skills load`, `activate`, `active`, and `deactivate` through the agent-facing CLI workflow. Activation and cleanup preview by default; approved changes require `--yes`. diff --git a/README.md b/README.md index 8505ff89..e3afffe5 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,14 @@ selftune is empirical. It observes real sessions, grades execution quality, dete ## Commands +Find local skills without installing them: +`selftune skills search "task or collection" --json`. +Search includes inactive Library packages and returns up to five matches by default. + +For temporary project use, your agent selects results, previews `skills activate`, +loads the chosen instructions, then runs `skills deactivate` for its task. Local +Library copies and pre-existing project skills stay intact. + Your agent runs these — you just say what you want ("improve my skills", "show the dashboard"). | Group | Command | What it does | diff --git a/apps/cli/src/effect-cli/commands/skills-activation.ts b/apps/cli/src/effect-cli/commands/skills-activation.ts new file mode 100644 index 00000000..23a58c0c --- /dev/null +++ b/apps/cli/src/effect-cli/commands/skills-activation.ts @@ -0,0 +1,125 @@ +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Command from "effect/unstable/cli/Command"; +import * as Flag from "effect/unstable/cli/Flag"; +import { LibraryError } from "@selftune/library"; +import { CLIError } from "@selftune/runtime/utils/cli-error"; +import { + activateSkills, + activeSkills, + deactivateSkills, + loadSkill, + previewActivation, + previewDeactivation, + type SkillSelection, +} from "@selftune/runtime/skill-search/activation"; + +const execute = Effect.fn("selftune.skills.activation")(function* (run: () => A) { + const result = yield* Effect.try({ + try: run, + catch: (error) => + error instanceof CLIError + ? error + : error instanceof LibraryError + ? new CLIError(error.message, error.code, error.suggestion, error.exitCode) + : new CLIError( + error instanceof Error ? error.message : String(error), + "OPERATION_FAILED", + "selftune skills active --json", + ), + }); + yield* Console.log(JSON.stringify(result, null, 2)); +}); + +export function makeSkillsActivationCommands() { + const project = Flag.string("project").pipe(Flag.withDefault(".")); + const json = Flag.boolean("json"); + const task = Flag.string("task"); + const approval = { yes: Flag.boolean("yes"), dryRun: Flag.boolean("dry-run") }; + const lookup = { searchDirs: Flag.string("search-dir").pipe(Flag.atLeast(0)) }; + const activate = Command.make( + "activate", + { + project, + task, + json, + ...approval, + ...lookup, + ids: Flag.string("id").pipe(Flag.atLeast(0)), + set: Flag.string("set").pipe(Flag.optional), + harness: Flag.choice("harness", ["codex", "claude_code", "opencode", "openclaw", "pi"]), + }, + (input) => + execute(() => { + const setId = Option.getOrUndefined(input.set); + if ((setId !== undefined) === input.ids.length > 0) + throw new CLIError( + "Choose --id (repeatable) or --set, but not both.", + "INVALID_ARGUMENT", + ); + const selection: SkillSelection = setId === undefined ? { ids: input.ids } : { setId }; + const options = { + project: input.project, + task: input.task, + harness: input.harness, + selection, + searchDirs: input.searchDirs.length ? input.searchDirs : undefined, + }; + return !input.yes || input.dryRun + ? { status: "preview", plan: previewActivation(options) } + : { status: "active", receipt: activateSkills(options) }; + }), + ).pipe( + Command.withDescription( + "Temporarily activate selected local skills for a task; previews unless --yes.", + ), + ); + const active = Command.make( + "active", + { project, json, task: task.pipe(Flag.optional) }, + (input) => + execute(() => ({ + activations: activeSkills({ + project: input.project, + task: Option.getOrUndefined(input.task), + }), + })), + ).pipe(Command.withDescription("List unfinished temporary skill activations in this project.")); + const deactivate = Command.make( + "deactivate", + { + project, + json, + ...approval, + task: task.pipe(Flag.optional), + receipt: Flag.string("receipt").pipe(Flag.optional), + }, + (input) => + execute(() => { + const taskId = Option.getOrUndefined(input.task); + const receiptId = Option.getOrUndefined(input.receipt); + if ((taskId === undefined) === (receiptId === undefined)) + throw new CLIError("Choose --task or --receipt, but not both.", "INVALID_ARGUMENT"); + const owner = taskId !== undefined ? { task: taskId } : { receipt: receiptId ?? "" }; + const options = { project: input.project, owner }; + return !input.yes || input.dryRun + ? { status: "preview", plans: previewDeactivation(options) } + : { status: "cleaned", receipts: deactivateSkills(options) }; + }), + ).pipe( + Command.withDescription( + "Remove only this task's temporary project paths; previews unless --yes.", + ), + ); + const load = Command.make("load", { id: Flag.string("id"), json, ...lookup }, (input) => + execute(() => + loadSkill(input.id, { searchDirs: input.searchDirs.length ? input.searchDirs : undefined }), + ), + ).pipe( + Command.withDescription( + "Read one exact local skill revision without installing or executing it.", + ), + ); + return [activate, active, deactivate, load]; +} diff --git a/apps/cli/src/effect-cli/commands/skills-search.ts b/apps/cli/src/effect-cli/commands/skills-search.ts new file mode 100644 index 00000000..a214f150 --- /dev/null +++ b/apps/cli/src/effect-cli/commands/skills-search.ts @@ -0,0 +1,40 @@ +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Argument from "effect/unstable/cli/Argument"; +import * as Command from "effect/unstable/cli/Command"; +import * as Flag from "effect/unstable/cli/Flag"; +import { searchLocalSkillsEffect } from "@selftune/runtime/skill-search/search"; + +export function makeSkillsSearchCommand() { + return Command.make( + "search", + { + query: Argument.string("query"), + limit: Flag.integer("limit").pipe(Flag.withDefault(5)), + searchDirs: Flag.string("search-dir").pipe(Flag.atLeast(0)), + json: Flag.boolean("json"), + }, + Effect.fn(function* ({ query, limit, searchDirs, json }) { + const result = yield* searchLocalSkillsEffect({ + query, + limit, + searchDirs: searchDirs.length ? searchDirs : undefined, + }); + yield* Console.log( + json + ? JSON.stringify(result, null, 2) + : [ + ...result.results.map( + (hit) => `${hit.name} (${hit.id})\n ${hit.description}\n ${hit.skill_path}`, + ), + ...(result.results.length ? [] : ["No matching local skills."]), + ...result.warnings.map((warning) => `Warning: ${warning.path}: ${warning.message}`), + ].join("\n"), + ); + }), + ).pipe( + Command.withDescription( + "Search local skills and inactive Library packages with BM25; does not install or execute.", + ), + ); +} diff --git a/apps/cli/src/effect-cli/commands/skills.ts b/apps/cli/src/effect-cli/commands/skills.ts index be8be1a8..6a66ffa7 100644 --- a/apps/cli/src/effect-cli/commands/skills.ts +++ b/apps/cli/src/effect-cli/commands/skills.ts @@ -3,6 +3,8 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Command from "effect/unstable/cli/Command"; import * as Flag from "effect/unstable/cli/Flag"; +import { makeSkillsSearchCommand } from "./skills-search.js"; +import { makeSkillsActivationCommands } from "./skills-activation.js"; import { SKILLS_HELP } from "@selftune/runtime/skill-portfolio/help"; import type { @@ -345,6 +347,8 @@ export function makeSkillsCommand(actions: SkillsCommandActions = makeLiveSkills ({ internalHelp }) => (internalHelp ? Console.log(SKILLS_HELP) : Effect.void), ).pipe( Command.withSubcommands([ + makeSkillsSearchCommand(), + ...makeSkillsActivationCommands(), audit, quarantined, quarantine, diff --git a/apps/cli/src/effect-cli/compatibility/skills.ts b/apps/cli/src/effect-cli/compatibility/skills.ts index 3159e6ed..b57f3b80 100644 --- a/apps/cli/src/effect-cli/compatibility/skills.ts +++ b/apps/cli/src/effect-cli/compatibility/skills.ts @@ -191,6 +191,12 @@ function prepareConsolidationRollback(args: ReadonlyArray): ReadonlyArra function prepareLeaf(subcommand: string, args: ReadonlyArray): ReadonlyArray { switch (subcommand) { + case "search": + case "activate": + case "active": + case "deactivate": + case "load": + return [subcommand, ...args]; case "audit": return prepareAudit(args); case "quarantined": diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 1eaa8449..73d6f161 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -61,6 +61,9 @@ process.on("unhandledRejection", handleCLIError); const originalArgv = process.argv.slice(2); const command = process.argv[2]; +const isSkillSearch = + command === "skills" && + ["search", "load", "activate", "active", "deactivate"].includes(process.argv[3] ?? ""); const internalCommand = command === INTERNAL_PACKAGE_COLLECTOR_COMMAND || command === INTERNAL_PACKAGE_BUNDLE_SMOKE_COMMAND; @@ -90,7 +93,7 @@ Usage: Primary Lifecycle: status Show skill health summary - skills Audit, consolidate, archive, and restore installed skills + skills Search, audit, consolidate, archive, and restore skills library Reconcile all installed, cached, draft, and archived skills sets Build and materialize reusable project Skill Sets verify Verify a draft skill package @@ -154,6 +157,7 @@ const FAST_COMMANDS: ReadonlySet = new Set([ // Track command usage (lazy import — skip for hooks and --help to avoid loading crypto/os) if ( !internalCommand && + !isSkillSearch && command && !FAST_COMMANDS.has(command) && command !== "--help" && @@ -167,6 +171,7 @@ if ( // Advisory update check (skip for hooks and platform hook commands — they must be fast — and --help) if ( !internalCommand && + !isSkillSearch && command && !FAST_COMMANDS.has(command) && command !== "--help" && @@ -181,8 +186,11 @@ if (!internalCommand && !command) { const { runEffectCliMain } = await import("./effect-cli/runtime.js"); runEffectCliMain("status", []); } else if (!internalCommand) { - const { startDashboardActionStream } = await import("@selftune/runtime/dashboard-action-stream"); - startDashboardActionStream(originalArgv); + if (!isSkillSearch) { + const { startDashboardActionStream } = + await import("@selftune/runtime/dashboard-action-stream"); + startDashboardActionStream(originalArgv); + } // Route to the appropriate subcommand module. // We use dynamic imports so only the needed module is loaded. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0af95115..170c39e8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@selftune/desktop", - "version": "0.4.9", + "version": "0.4.10", "private": true, "description": "Native desktop control plane for SelfTune skill observability and improvement", "homepage": "https://selftune.dev", diff --git a/apps/desktop/src/main/desktop-ipc.ts b/apps/desktop/src/main/desktop-ipc.ts index b9ac4c25..93fe13a1 100644 --- a/apps/desktop/src/main/desktop-ipc.ts +++ b/apps/desktop/src/main/desktop-ipc.ts @@ -25,6 +25,7 @@ const IPC_CHANNELS = [ "selftune:choose-folder", "selftune:restart-service", "selftune:check-for-updates", + "selftune:update-status", "selftune:background-service", "selftune:set-background-service", "selftune:export-diagnostics", @@ -110,6 +111,11 @@ export function registerDesktopIpc(options: DesktopIpcOptions): DesktopIpcContro options.window.assertTrustedIpc(event); return options.shell.checkForUpdates(true); }); + ipcMain.handle("selftune:update-status", (event, ...input: unknown[]) => { + options.window.assertTrustedIpc(event); + decodeDesktopBootstrapNoInput(input); + return options.shell.getUpdateStatus(); + }); ipcMain.handle("selftune:background-service", (event) => { options.window.assertTrustedIpc(event); return options.runRuntime(options.runtime.backgroundServiceState); diff --git a/apps/desktop/src/main/desktop-shell.ts b/apps/desktop/src/main/desktop-shell.ts index c9dae325..009737e2 100644 --- a/apps/desktop/src/main/desktop-shell.ts +++ b/apps/desktop/src/main/desktop-shell.ts @@ -19,6 +19,7 @@ import { type TraySettingsResponse, } from "./tray-state"; import { createDesktopUpdater, type DesktopUpdaterController } from "./updater"; +import type { DesktopUpdateStatus } from "./update-state"; const SyncResultSchema = Schema.Struct({ success: Schema.Boolean, @@ -33,6 +34,7 @@ export interface DesktopShellOptions { } export interface DesktopShellController { + readonly getUpdateStatus: () => DesktopUpdateStatus; readonly checkForUpdates: (interactive: boolean) => Promise; readonly createTray: () => Promise; readonly destroy: () => void; @@ -138,6 +140,7 @@ export function createDesktopShell(options: DesktopShellOptions): DesktopShellCo updaterController = createDesktopUpdater( (status) => trayController?.setUpdateStatus(status), () => options.runRuntime(options.runtime.prepareForUpdate), + options.window.beginShutdown, ); installApplicationMenu(); } @@ -191,6 +194,7 @@ export function createDesktopShell(options: DesktopShellOptions): DesktopShellCo updaterController = null; Menu.setApplicationMenu(null); }, + getUpdateStatus: () => updaterController?.getStatus() ?? { state: "idle" }, installUpdate: () => updaterController?.install() ?? Promise.resolve(), refreshTray: () => trayController?.refresh() ?? Promise.resolve(), start, diff --git a/apps/desktop/src/main/updater-lifecycle.fixture.ts b/apps/desktop/src/main/updater-lifecycle.fixture.ts new file mode 100644 index 00000000..76682185 --- /dev/null +++ b/apps/desktop/src/main/updater-lifecycle.fixture.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { EventEmitter } from "node:events"; + +const nativeUpdater = new EventEmitter(); +const events: string[] = []; +let closingAllowed = false; +const autoUpdater = Object.assign(new EventEmitter(), { + autoDownload: false, + autoInstallOnAppQuit: true, + checkForUpdates: async () => ({ isUpdateAvailable: true }), + quitAndInstall: () => { + nativeUpdater.emit("before-quit-for-update"); + events.push(closingAllowed ? "windows-closed" : "close-prevented"); + if (closingAllowed) events.push("before-quit"); + }, +}); + +mock.module("electron", () => ({ + app: { isPackaged: true, getVersion: () => "0.4.9" }, + autoUpdater: nativeUpdater, + dialog: { showMessageBox: async () => ({ response: 1 }) }, +})); +mock.module("electron-updater", () => ({ default: { autoUpdater } })); + +const { createDesktopUpdater } = await import("./updater"); +const controllers: ReturnType[] = []; +afterEach(() => { + for (const controller of controllers.splice(0)) controller.destroy(); + events.length = 0; + closingAllowed = false; +}); + +describe("update restart lifecycle", () => { + it("allows window closure before Electron emits the ordinary before-quit event", async () => { + const controller = createDesktopUpdater( + () => undefined, + async () => { + events.push("runtime-prepared"); + }, + () => { + closingAllowed = true; + events.push("allow-close"); + }, + ); + controllers.push(controller); + autoUpdater.emit("update-downloaded", { version: "0.4.10" }); + await controller.install(); + expect(events).toEqual(["runtime-prepared", "allow-close", "windows-closed", "before-quit"]); + controller.destroy(); + expect(nativeUpdater.listenerCount("before-quit-for-update")).toBe(0); + }); + + it("does not enter quitting state when runtime preparation fails", async () => { + const controller = createDesktopUpdater( + () => undefined, + async () => { + throw new Error("runtime busy"); + }, + () => { + closingAllowed = true; + }, + ); + controllers.push(controller); + autoUpdater.emit("update-downloaded", { version: "0.4.10" }); + await expect(controller.install()).rejects.toThrow("runtime busy"); + expect(closingAllowed).toBe(false); + expect(events).toEqual([]); + }); +}); diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts new file mode 100644 index 00000000..3e71f465 --- /dev/null +++ b/apps/desktop/src/main/updater.test.ts @@ -0,0 +1,15 @@ +import { expect, it } from "bun:test"; +import { fileURLToPath } from "node:url"; + +it("passes the updater lifecycle regression in an isolated Electron mock process", () => { + const result = Bun.spawnSync( + [ + process.execPath, + "test", + fileURLToPath(new URL("./updater-lifecycle.fixture.ts", import.meta.url)), + ], + { env: { ...process.env, SELFTUNE_TEST_DISABLE_UPDATES: "0" } }, + ); + expect(result.stdout.toString() + result.stderr.toString()).toContain("2 pass"); + expect(result.exitCode).toBe(0); +}); diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 39ff9d3b..84975dc4 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -1,4 +1,4 @@ -import { app, dialog } from "electron"; +import { app, autoUpdater as nativeUpdater, dialog } from "electron"; import updater from "electron-updater"; import type { DesktopUpdateStatus } from "./update-state"; @@ -24,6 +24,7 @@ export interface DesktopUpdaterController { export function createDesktopUpdater( onStatus: (status: DesktopUpdateStatus) => void, beforeInstall: () => Promise, + beforeUpdateQuit: () => void, ): DesktopUpdaterController { const updatesDisabled = process.env.SELFTUNE_TEST_DISABLE_UPDATES === "1"; let status: DesktopUpdateStatus = { state: "idle" }; @@ -112,6 +113,7 @@ export function createDesktopUpdater( }; if (app.isPackaged && !updatesDisabled) { + nativeUpdater.on("before-quit-for-update", beforeUpdateQuit); autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = false; autoUpdater.on("update-available", (info: UpdateInfo) => { @@ -147,6 +149,7 @@ export function createDesktopUpdater( install, destroy() { if (interval) clearInterval(interval); + nativeUpdater.removeListener("before-quit-for-update", beforeUpdateQuit); autoUpdater.removeAllListeners(); }, }; diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index c4f12419..5513161f 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -5,6 +5,7 @@ import { PENDING_WINDOW_IPC_TEST_PRELOAD_ARGUMENT, } from "../desktop-test-contract"; import type { DesktopThisMacProfile } from "../main/this-mac-profile"; +import type { DesktopUpdateStatus } from "../main/update-state"; import type { DesktopInstallBootstrapPreviewResult, DesktopInstallBootstrapPublicState, @@ -63,6 +64,9 @@ const desktop = { checkForUpdates(): Promise { return ipcRenderer.invoke("selftune:check-for-updates"); }, + getUpdateStatus(): Promise { + return ipcRenderer.invoke("selftune:update-status"); + }, getBackgroundService(): Promise<{ detail: ReadonlyArray; enabled: boolean; diff --git a/apps/local-dashboard/src/App.tsx b/apps/local-dashboard/src/App.tsx index 18bd559e..04c56327 100644 --- a/apps/local-dashboard/src/App.tsx +++ b/apps/local-dashboard/src/App.tsx @@ -40,6 +40,7 @@ import { useOverview } from "@/hooks/useOverview"; import { useSSE } from "@/hooks/useSSE"; import { useLinkCloudAccount, useSettings } from "@/hooks/useSettings"; import { useStaleClient } from "@/hooks/use-stale-client"; +import { useDesktopUpdate } from "@/hooks/use-desktop-update"; import { Insights } from "@/pages/Insights"; import { LiveRun } from "@/pages/LiveRun"; import { SkillReport } from "@/pages/SkillReport"; @@ -184,6 +185,7 @@ function getLocalHeaderMeta( function DashboardShell({ runtime }: { runtime: ServerRuntimeProfile }) { useSSE(); const staleClient = useStaleClient(); + const desktopUpdate = useDesktopUpdate(); const overviewQuery = useOverview(); const settingsQuery = useSettings(); const cloudAccountLink = useLinkCloudAccount(); @@ -298,13 +300,15 @@ function DashboardShell({ runtime }: { runtime: ServerRuntimeProfile }) { name: "selftune", footerLabel: data?.version ? `selftune v${data.version}` : "selftune", footerHref: "/status", - footerAction: staleClient - ? { - label: "Update", - ariaLabel: `Update dashboard to v${staleClient.serverVersion}`, - onClick: () => window.location.reload(), - } - : undefined, + footerAction: + desktopUpdate ?? + (staleClient + ? { + label: "Update", + ariaLabel: `Update dashboard to v${staleClient.serverVersion}`, + onClick: () => window.location.reload(), + } + : undefined), }} navItems={navItems} renderLink={renderRouterLink} diff --git a/apps/local-dashboard/src/desktop-bridge.d.ts b/apps/local-dashboard/src/desktop-bridge.d.ts index d9e7aca5..31fbb5c9 100644 --- a/apps/local-dashboard/src/desktop-bridge.d.ts +++ b/apps/local-dashboard/src/desktop-bridge.d.ts @@ -7,6 +7,10 @@ interface SelfTuneBackgroundServiceState { } interface SelfTuneDesktopBridge { + readonly getUpdateStatus?: () => Promise< + import("../../desktop/src/main/update-state").DesktopUpdateStatus + >; + readonly checkForUpdates?: () => Promise; readonly focus: () => Promise; readonly getBackgroundService: () => Promise; readonly getThisMacProfile: () => Promise<{ diff --git a/apps/local-dashboard/src/hooks/use-desktop-update.test.tsx b/apps/local-dashboard/src/hooks/use-desktop-update.test.tsx new file mode 100644 index 00000000..e28488d4 --- /dev/null +++ b/apps/local-dashboard/src/hooks/use-desktop-update.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ReactNode } from "react"; +import type { DesktopUpdateStatus } from "../../../desktop/src/main/update-state"; +import { useDesktopUpdate } from "./use-desktop-update"; + +afterEach(() => { + cleanup(); + Object.defineProperty(window, "selftuneDesktop", { + value: undefined, + configurable: true, + }); +}); + +function setup(status?: DesktopUpdateStatus) { + const check = vi.fn(async () => undefined); + if (status) + Object.defineProperty(window, "selftuneDesktop", { + value: { getUpdateStatus: async () => status, checkForUpdates: check }, + configurable: true, + }); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return { ...renderHook(useDesktopUpdate, { wrapper }), check }; +} + +describe("native sidebar update action", () => { + it("stays hidden in browsers and older desktop bridges", () => { + expect(setup().result.current).toBeUndefined(); + }); + it("offers the native restart confirmation for a downloaded update", async () => { + const { result, check } = setup({ state: "downloaded", version: "0.4.11" }); + await waitFor(() => expect(result.current?.label).toBe("Update")); + expect(result.current?.ariaLabel).toContain("restart required"); + expect(result.current?.disabled).toBe(false); + result.current?.onClick(); + expect(check).toHaveBeenCalledOnce(); + }); + it("shows download progress without allowing an early restart", async () => { + const { result } = setup({ + state: "downloading", + version: "0.4.11", + percent: 43, + }); + await waitFor(() => expect(result.current?.label).toBe("43%")); + expect(result.current?.disabled).toBe(true); + }); + it("exposes a retry action on updater failure", async () => { + const { result, check } = setup({ state: "error", message: "Offline" }); + await waitFor(() => expect(result.current?.label).toBe("Retry")); + expect(result.current?.ariaLabel).toContain("Offline"); + result.current?.onClick(); + expect(check).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/local-dashboard/src/hooks/use-desktop-update.tsx b/apps/local-dashboard/src/hooks/use-desktop-update.tsx new file mode 100644 index 00000000..d728ed52 --- /dev/null +++ b/apps/local-dashboard/src/hooks/use-desktop-update.tsx @@ -0,0 +1,62 @@ +import { useQuery } from "@tanstack/react-query"; +import { DownloadIcon, LoaderCircleIcon, RefreshCwIcon } from "lucide-react"; +import { toast } from "sonner"; +import type { DashboardBrand } from "@selftune/dashboard-core/chrome"; + +export function useDesktopUpdate(): DashboardBrand["footerAction"] { + const desktop = window.selftuneDesktop; + const status = useQuery({ + queryKey: ["desktop-update-status"], + queryFn: () => desktop?.getUpdateStatus?.() ?? Promise.resolve({ state: "idle" } as const), + enabled: Boolean(desktop?.getUpdateStatus), + refetchInterval: 5_000, + retry: false, + }); + const update = status.data; + if ( + !desktop?.checkForUpdates || + !update || + update.state === "idle" || + update.state === "checking" + ) + return undefined; + const downloading = update.state === "available" || update.state === "downloading"; + const label = + update.state === "error" + ? "Retry" + : update.state === "downloading" + ? `${update.percent}%` + : downloading + ? "Loading" + : "Update"; + const ariaLabel = + update.state === "downloaded" + ? `Update SelfTune to v${update.version} — restart required` + : update.state === "error" + ? `Update failed: ${update.message}. Retry update check` + : `Downloading SelfTune v${update.version}${update.state === "downloading" ? ` (${update.percent}%)` : ""}`; + return { + label, + ariaLabel, + disabled: downloading, + icon: downloading ? ( + + ) : update.state === "error" ? ( + + ) : ( + + ), + onClick: () => { + void desktop + .checkForUpdates?.() + .catch((cause: unknown) => { + toast.error("Could not update SelfTune", { + description: cause instanceof Error ? cause.message : String(cause), + }); + }) + .finally(() => { + void status.refetch(); + }); + }, + }; +} diff --git a/bun.lock b/bun.lock index 5822f4b0..396153cd 100644 --- a/bun.lock +++ b/bun.lock @@ -82,7 +82,7 @@ }, "apps/desktop": { "name": "@selftune/desktop", - "version": "0.4.9", + "version": "0.4.10", "dependencies": { "@selftune/config": "workspace:*", "@sentry/electron": "^7.8.0", diff --git a/package.json b/package.json index 8d35df7c..11dd5c21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "selftune", - "version": "0.4.9", + "version": "0.4.10", "description": "Skill-level observability and self-improvement for AI agents — monitors skill routing, detects missed triggers, and evolves descriptions automatically", "keywords": [ "agent", diff --git a/packages/dashboard-core/src/chrome/DashboardSidebar.stories.tsx b/packages/dashboard-core/src/chrome/DashboardSidebar.stories.tsx new file mode 100644 index 00000000..fdc98d92 --- /dev/null +++ b/packages/dashboard-core/src/chrome/DashboardSidebar.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DownloadIcon } from "lucide-react"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import { DashboardSidebar } from "./DashboardSidebar"; + +const meta = { + title: "Chrome/Sidebar update", + component: DashboardSidebar, + parameters: { layout: "fullscreen" }, + args: { + brand: { + name: "selftune", + href: "/", + footerLabel: "selftune v0.4.11", + footerAction: { + label: "Update", + ariaLabel: "Update SelfTune — restart required", + icon: , + onClick: () => undefined, + }, + }, + navItems: [], + renderLink: ({ children, href }) => {children}, + onOpenCommands: () => undefined, + mobileOpen: true, + onMobileOpenChange: () => undefined, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const UpdateReady: Story = { + play: async ({ canvasElement }) => { + const button = within(canvasElement).getByRole("button", { + name: "Update SelfTune — restart required", + }); + const [icon, label] = button.querySelectorAll("span[aria-hidden]"); + await expect(icon).toHaveStyle({ opacity: "1" }); + await expect(label).toHaveStyle({ opacity: "0" }); + await userEvent.hover(button); + button.focus(); + await waitFor(() => expect(label).toHaveStyle({ opacity: "1" })); + await expect(icon).toHaveStyle({ opacity: "0" }); + await userEvent.unhover(button); + }, +}; diff --git a/packages/dashboard-core/src/chrome/DashboardSidebar.test.tsx b/packages/dashboard-core/src/chrome/DashboardSidebar.test.tsx new file mode 100644 index 00000000..1d958606 --- /dev/null +++ b/packages/dashboard-core/src/chrome/DashboardSidebar.test.tsx @@ -0,0 +1,38 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { DownloadIcon } from "lucide-react"; +import { DashboardSidebar } from "./DashboardSidebar"; + +afterEach(cleanup); + +it("renders an accessible icon update action and invokes it on click", () => { + const onClick = vi.fn(); + render( + , + onClick, + }, + }} + navItems={[]} + renderLink={({ children, href }) => {children}} + onOpenCommands={() => undefined} + mobileOpen={false} + onMobileOpenChange={() => undefined} + />, + ); + const button = screen.getByRole("button", { + name: "Update SelfTune — restart required", + }); + expect(button.querySelector("svg")).not.toBeNull(); + expect(button.textContent).toBe("Update"); + fireEvent.click(button); + expect(onClick).toHaveBeenCalledOnce(); +}); diff --git a/packages/dashboard-core/src/chrome/DashboardSidebar.tsx b/packages/dashboard-core/src/chrome/DashboardSidebar.tsx index 16a94905..bb773b6c 100644 --- a/packages/dashboard-core/src/chrome/DashboardSidebar.tsx +++ b/packages/dashboard-core/src/chrome/DashboardSidebar.tsx @@ -204,13 +204,32 @@ export function DashboardSidebar({ variant="ghost" size="xs" aria-label={brand.footerAction.ariaLabel ?? brand.footerAction.label} - className="text-sidebar-primary hover:text-sidebar-primary" + title={brand.footerAction.ariaLabel ?? brand.footerAction.label} + disabled={brand.footerAction.disabled} + className="group/update relative min-w-16 text-sidebar-primary hover:text-sidebar-primary" onClick={() => { onMobileOpenChange(false); brand.footerAction?.onClick(); }} > - {brand.footerAction.label} + {brand.footerAction.icon ? ( + <> + + + + ) : ( + brand.footerAction.label + )} ) : null} diff --git a/packages/dashboard-core/src/chrome/types.ts b/packages/dashboard-core/src/chrome/types.ts index bcbb066f..730b039d 100644 --- a/packages/dashboard-core/src/chrome/types.ts +++ b/packages/dashboard-core/src/chrome/types.ts @@ -49,6 +49,8 @@ export interface DashboardBrand { footerAction?: { label: string; ariaLabel?: string; + icon?: ReactNode; + disabled?: boolean; onClick(): void; }; } diff --git a/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.stories.tsx b/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.stories.tsx new file mode 100644 index 00000000..dd1c947b --- /dev/null +++ b/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.stories.tsx @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import { ShareSkillSetDialog } from "./ShareSkillSetDialog"; + +const lines = Array.from( + { length: 180 }, + (_, index) => + `+Line ${index}: Review the bundled instructions, scripts, references, and campaign assets before sharing.`, +); +const patch = [ + "diff --git a/SKILL.md b/SKILL.md", + "--- /dev/null", + "+++ b/SKILL.md", + `@@ -0,0 +1,${lines.length} @@`, + ...lines, +].join("\n"); + +const meta = { + component: ShareSkillSetDialog, + args: { + open: true, + onOpenChange: () => {}, + skillSet: { + id: "ithraa", + name: "Ithraa Skills", + description: "License review", + connections: ["codex"], + revision: 1, + revisionHash: "a".repeat(64), + updatedAt: "2026-09-03T00:00:00Z", + skills: [ + { + name: "marketing-social", + packagePath: "/library/marketing-social", + contentHash: "b".repeat(64), + }, + ], + }, + action: { + access: "available", + execute: async () => { + throw new Error("Missing license"); + }, + }, + previewLicenseAction: { + access: "available", + execute: async () => ({ + previewId: "draft", + skillPath: "/library/marketing-social", + licenseExpression: "LicenseRef-Ithraa-Proprietary", + files: [{ path: "SKILL.md", patch }], + }), + }, + applyLicenseAction: { + access: "available", + execute: async () => { + throw new Error("Story does not apply licenses"); + }, + }, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const LongLicenseDiff: Story = { + play: async () => { + const screen = within(document.body); + await userEvent.click(screen.getByRole("button", { name: "Create link" })); + await userEvent.click(await screen.findByRole("button", { name: "Draft missing license" })); + await userEvent.type(screen.getByLabelText("Copyright holder"), "Daniel Petro"); + await userEvent.type(screen.getByLabelText("Licensed organization"), "Ithraa Center"); + await userEvent.click(screen.getByRole("button", { name: "Review draft" })); + const apply = await screen.findByRole("button", { name: "Apply reviewed license" }); + const diff = screen.getByLabelText("License file diff"); + + await waitFor(() => expect(diff.scrollHeight).toBeGreaterThan(diff.clientHeight), { + timeout: 10000, + }); + const dialog = screen.getByRole("dialog").getBoundingClientRect(); + expect(dialog.top).toBeGreaterThanOrEqual(0); + expect(dialog.bottom).toBeLessThanOrEqual(window.innerHeight); + expect(dialog.left).toBeGreaterThanOrEqual(0); + expect(dialog.right).toBeLessThanOrEqual(window.innerWidth); + expect(apply.getBoundingClientRect().bottom).toBeLessThanOrEqual(window.innerHeight); + diff.scrollTop = diff.scrollHeight; + expect(diff.scrollTop).toBeGreaterThan(0); + expect(apply.getBoundingClientRect().bottom).toBeLessThanOrEqual(window.innerHeight); + }, +}; diff --git a/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.test.tsx b/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.test.tsx index 7bbbd1e7..da34c19b 100644 --- a/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.test.tsx +++ b/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.test.tsx @@ -256,4 +256,53 @@ describe("ShareSkillSetDialog", () => { fireEvent.click(screen.getByRole("button", { name: "Review draft" })); await waitFor(() => expect(preview).toHaveBeenCalledTimes(2)); }); + it.each(["different Set", "new revision"])( + "uses the current skill after selecting a %s", + async (change) => { + const preview = vi.fn(async () => { + throw new Error("Preview captured"); + }); + const props = { + action: action( + vi.fn(async () => { + throw new Error("Missing license"); + }), + ), + previewLicenseAction: { access: "available" as const, execute: preview }, + applyLicenseAction: { access: "available" as const, execute: vi.fn() }, + onOpenChange: () => {}, + }; + const { rerender } = render( + , + ); + const ithraa = { + ...skillSet, + id: change === "different Set" ? "ithraa-skills" : skillSet.id, + revisionHash: "d".repeat(64), + name: "Ithraa Skills", + skills: [ + { + name: "marketing-social", + packagePath: "/library/marketing-social", + contentHash: "c".repeat(64), + }, + ], + }; + rerender(); + fireEvent.click(screen.getByRole("button", { name: "Create link" })); + fireEvent.click(await screen.findByRole("button", { name: "Draft missing license" })); + fireEvent.change(screen.getByLabelText("Copyright holder"), { + target: { value: "Daniel Petro" }, + }); + fireEvent.change(screen.getByLabelText("Licensed organization"), { + target: { value: "Ithraa Center" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Review draft" })); + await waitFor(() => + expect(preview).toHaveBeenCalledWith( + expect.objectContaining({ skillId: "marketing-social", skillSetId: ithraa.id }), + ), + ); + }, + ); }); diff --git a/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.tsx b/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.tsx index 510e4dac..c378f7a1 100644 --- a/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.tsx +++ b/packages/dashboard-core/src/screens/projects/ShareSkillSetDialog.tsx @@ -38,7 +38,7 @@ const EMPTY_SHARE_RECIPIENTS: ReturnType< const DEFAULT_DIFF_REVIEW = PierreDiffReview; type DiffReviewComponent = ComponentType>; -export function ShareSkillSetDialog({ +function ShareSkillSetDialogContent({ skillSet, open, onOpenChange, @@ -175,8 +175,14 @@ export function ShareSkillSetDialog({ return ( - - + + {draftingLicense ? `Draft license for ${skillSet.name}` @@ -189,7 +195,7 @@ export function ShareSkillSetDialog({ {draftingLicense ? ( -
+
{licensePreview ? ( <>
@@ -420,7 +426,7 @@ export function ShareSkillSetDialog({ ) : null}
) : null} - + {draftingLicense ? (