diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index df4abcec..1f8616bf 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -23,6 +23,8 @@ const execAsync = promisify(exec); import { RunEvent } from '@x/shared/dist/runs.js'; import { ServiceEvent } from '@x/shared/dist/service-events.js'; import container from '@x/core/dist/di/container.js'; +import type { ISkillsRepo } from '@x/core/dist/skills/repo.js'; +import type { ISkillResolver } from '@x/core/dist/skills/resolver.js'; import { listOnboardingModels } from '@x/core/dist/models/models-dev.js'; import { testModelConnection } from '@x/core/dist/models/models.js'; import { isSignedIn } from '@x/core/dist/account/account.js'; @@ -722,6 +724,30 @@ export function setupIpcHandlers() { 'voice:getDeepgramToken': async () => { return voice.getDeepgramToken(); }, + // Skills handlers + 'skills:list': async () => { + const resolver = container.resolve('skillResolver'); + const skills = await resolver.getCatalog(); + return { skills }; + }, + 'skills:get': async (_event, args) => { + const resolver = container.resolve('skillResolver'); + return await resolver.resolve(args.id); + }, + 'skills:getOfficial': async (_event, args) => { + const resolver = container.resolve('skillResolver'); + return resolver.getOfficial(args.id); + }, + 'skills:saveOverride': async (_event, args) => { + const repo = container.resolve('skillsRepo'); + await repo.saveOverride(args.skillId, args.meta, args.content); + return { success: true as const }; + }, + 'skills:deleteOverride': async (_event, args) => { + const repo = container.resolve('skillsRepo'); + await repo.deleteOverride(args.skillId); + return { success: true as const }; + }, // Billing handler 'billing:getInfo': async () => { return await getBillingInfo(); diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index f7df586b..70e3d386 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback, useMemo } from "react" -import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Tags, Mail, BookOpen, ChevronRight, Plus, X, User, Plug } from "lucide-react" +import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Tags, Mail, BookOpen, ChevronRight, Plus, X, User, Plug, Sparkles } from "lucide-react" import { Dialog, @@ -24,8 +24,9 @@ import { useTheme } from "@/contexts/theme-context" import { toast } from "sonner" import { AccountSettings } from "@/components/settings/account-settings" import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings" +import { SkillsSettings } from "@/components/settings/skills-settings" -type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "note-tagging" +export type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "note-tagging" | "skills" interface TabConfig { id: ConfigTab @@ -82,10 +83,17 @@ const tabs: TabConfig[] = [ path: "config/tags.json", description: "Configure tags for notes and emails", }, + { + id: "skills", + label: "Skills", + icon: Sparkles, + description: "View and customize copilot skills", + }, ] interface SettingsDialogProps { children: React.ReactNode + initialTab?: ConfigTab } // --- Theme option for Appearance tab --- @@ -1238,15 +1246,17 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) { // --- Main Settings Dialog --- -export function SettingsDialog({ children }: SettingsDialogProps) { +export function SettingsDialog({ children, initialTab }: SettingsDialogProps) { const [open, setOpen] = useState(false) - const [activeTab, setActiveTab] = useState("models") + const [activeTab, setActiveTab] = useState(initialTab ?? "models") const [content, setContent] = useState("") const [originalContent, setOriginalContent] = useState("") const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const [rowboatConnected, setRowboatConnected] = useState(false) + const [skillUpdateCount, setSkillUpdateCount] = useState(0) + const [skillsExpanded, setSkillsExpanded] = useState(false) // Check if user is signed in to Rowboat useEffect(() => { @@ -1259,6 +1269,24 @@ export function SettingsDialog({ children }: SettingsDialogProps) { }) }, [open]) + // Check for skill updates + useEffect(() => { + if (!open) return + window.ipc.invoke('skills:list', null).then((result) => { + const count = result.skills.filter((s: { hasUpdate?: boolean }) => s.hasUpdate).length + setSkillUpdateCount(count) + }).catch(() => { + setSkillUpdateCount(0) + }) + }, [open]) + + // Handle initialTab changes (e.g. when opened from sidebar notification) + useEffect(() => { + if (initialTab && open) { + setActiveTab(initialTab) + } + }, [initialTab, open]) + const visibleTabs = useMemo(() => tabs, []) const activeTabConfig = visibleTabs.find((t) => t.id === activeTab) ?? visibleTabs[0] @@ -1329,6 +1357,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) { }, [open, activeTab, isJsonTab, loadConfig]) const handleTabChange = (tab: ConfigTab) => { + if (tab !== "skills") setSkillsExpanded(false) if (isJsonTab && hasChanges) { if (!confirm("You have unsaved changes. Discard them?")) { return @@ -1341,7 +1370,12 @@ export function SettingsDialog({ children }: SettingsDialogProps) { {children}
{/* Sidebar */} @@ -1362,7 +1396,12 @@ export function SettingsDialog({ children }: SettingsDialogProps) { )} > - {tab.label} + {tab.label} + {tab.id === "skills" && skillUpdateCount > 0 && ( + + {skillUpdateCount} + + )} ))} @@ -1381,7 +1420,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
{/* Content */} -
+
{activeTab === "account" ? ( ) : activeTab === "connected-accounts" ? ( @@ -1394,6 +1433,8 @@ export function SettingsDialog({ children }: SettingsDialogProps) { ) : activeTab === "appearance" ? ( + ) : activeTab === "skills" ? ( + ) : loading ? (
Loading... diff --git a/apps/x/apps/renderer/src/components/settings/skills-settings.tsx b/apps/x/apps/renderer/src/components/settings/skills-settings.tsx new file mode 100644 index 00000000..89545dc1 --- /dev/null +++ b/apps/x/apps/renderer/src/components/settings/skills-settings.tsx @@ -0,0 +1,428 @@ +"use client" + +import * as React from "react" +import { useState, useEffect, useCallback, useMemo } from "react" +import { ArrowLeft, RotateCcw, Save, Pencil, ArrowUpCircle, X } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" +import { toast } from "sonner" +import type { ResolvedSkill, SkillOverride } from "@x/shared/dist/skill.js" + +type ViewMode = "view" | "edit" | "compare" + +interface SkillsSettingsProps { + dialogOpen: boolean + onExpandRequest?: (expanded: boolean) => void +} + +// ── Simple line-based diff ────────────────────────────────────────────── +type DiffLine = { type: "same" | "add" | "del"; text: string } + +function computeDiff(oldText: string, newText: string): DiffLine[] { + const oldLines = oldText.split("\n") + const newLines = newText.split("\n") + + // Simple LCS-based diff + const m = oldLines.length + const n = newLines.length + const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)) + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (oldLines[i - 1] === newLines[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1 + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]) + } + } + } + + // Backtrack to build diff + const result: DiffLine[] = [] + let i = m, j = n + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { + result.push({ type: "same", text: oldLines[i - 1] }) + i--; j-- + } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { + result.push({ type: "add", text: newLines[j - 1] }) + j-- + } else { + result.push({ type: "del", text: oldLines[i - 1] }) + i-- + } + } + + return result.reverse() +} + +function DiffView({ oldText, newText }: { oldText: string; newText: string }) { + const lines = useMemo(() => computeDiff(oldText, newText), [oldText, newText]) + const stats = useMemo(() => { + const added = lines.filter((l) => l.type === "add").length + const removed = lines.filter((l) => l.type === "del").length + return { added, removed } + }, [lines]) + + return ( +
+
+ +{stats.added} added + -{stats.removed} removed +
+
+
+          {lines.map((line, i) => (
+            
+ + {line.type === "add" ? "+" : line.type === "del" ? "-" : " "} + + {line.text || " "} +
+ ))} +
+
+
+ ) +} + +// ── Main component ────────────────────────────────────────────────────── + +export function SkillsSettings({ dialogOpen, onExpandRequest }: SkillsSettingsProps) { + const [skills, setSkills] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedSkill, setSelectedSkill] = useState(null) + const [editContent, setEditContent] = useState("") + const [officialContent, setOfficialContent] = useState("") + const [viewMode, setViewMode] = useState("view") + const [saving, setSaving] = useState(false) + + const loadSkills = useCallback(async () => { + try { + setLoading(true) + const result = await window.ipc.invoke("skills:list", null) + setSkills(result.skills) + } catch (err) { + console.error("Failed to load skills:", err) + toast.error("Failed to load skills") + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + if (dialogOpen) { + loadSkills() + } + }, [dialogOpen, loadSkills]) + + // Notify parent to expand/shrink when entering/leaving compare mode + useEffect(() => { + onExpandRequest?.(viewMode === "compare") + }, [viewMode, onExpandRequest]) + + const handleSelectSkill = useCallback(async (skillId: string) => { + try { + const skill = await window.ipc.invoke("skills:get", { id: skillId }) + if (skill) { + setSelectedSkill(skillId) + setEditContent(skill.content) + setViewMode("view") + } + } catch (err) { + console.error("Failed to load skill:", err) + toast.error("Failed to load skill") + } + }, []) + + const handleCustomize = useCallback(async () => { + if (!selectedSkill) return + setViewMode("edit") + }, [selectedSkill]) + + const handleSave = useCallback(async () => { + if (!selectedSkill) return + const skill = skills.find((s) => s.id === selectedSkill) + if (!skill) return + + try { + setSaving(true) + const meta: SkillOverride = { + base_skill_id: selectedSkill, + base_version: skill.version, + } + await window.ipc.invoke("skills:saveOverride", { + skillId: selectedSkill, + meta, + content: editContent, + }) + toast.success("Skill customization saved") + setViewMode("view") + await loadSkills() + const updated = await window.ipc.invoke("skills:get", { id: selectedSkill }) + if (updated) { + setEditContent(updated.content) + } + } catch (err) { + console.error("Failed to save skill override:", err) + toast.error("Failed to save") + } finally { + setSaving(false) + } + }, [selectedSkill, editContent, skills, loadSkills]) + + const handleReset = useCallback(async () => { + if (!selectedSkill) return + + try { + await window.ipc.invoke("skills:deleteOverride", { skillId: selectedSkill }) + toast.success("Skill reset to official version") + await loadSkills() + const official = await window.ipc.invoke("skills:get", { id: selectedSkill }) + if (official) { + setEditContent(official.content) + } + setViewMode("view") + } catch (err) { + console.error("Failed to reset skill:", err) + toast.error("Failed to reset") + } + }, [selectedSkill, loadSkills]) + + const handleCompareUpdate = useCallback(async () => { + if (!selectedSkill) return + try { + const official = await window.ipc.invoke("skills:getOfficial", { id: selectedSkill }) + if (official) { + setOfficialContent(official.content) + setViewMode("compare") + } + } catch (err) { + console.error("Failed to load official skill:", err) + toast.error("Failed to load official version") + } + }, [selectedSkill]) + + const handleAcceptUpdate = useCallback(async () => { + if (!selectedSkill) return + + try { + await window.ipc.invoke("skills:deleteOverride", { skillId: selectedSkill }) + toast.success("Updated to latest official version") + await loadSkills() + const updated = await window.ipc.invoke("skills:get", { id: selectedSkill }) + if (updated) { + setEditContent(updated.content) + } + setViewMode("view") + } catch (err) { + console.error("Failed to accept update:", err) + toast.error("Failed to accept update") + } + }, [selectedSkill, loadSkills]) + + const handleAcceptAndRecustomize = useCallback(async () => { + if (!selectedSkill) return + const skill = skills.find((s) => s.id === selectedSkill) + if (!skill) return + + try { + const meta: SkillOverride = { + base_skill_id: selectedSkill, + base_version: skill.version, + } + await window.ipc.invoke("skills:saveOverride", { + skillId: selectedSkill, + meta, + content: editContent, + }) + toast.success("Base version updated — your customizations are preserved") + await loadSkills() + setViewMode("edit") + } catch (err) { + console.error("Failed to update base version:", err) + toast.error("Failed to update") + } + }, [selectedSkill, editContent, skills, loadSkills]) + + const handleBack = useCallback(() => { + setSelectedSkill(null) + setViewMode("view") + }, []) + + const selectedSkillData = skills.find((s) => s.id === selectedSkill) + + if (loading) { + return ( +
+ Loading skills... +
+ ) + } + + // ── Compare view — unified diff ──────────────────────────────────── + if (selectedSkill && selectedSkillData && viewMode === "compare") { + return ( +
+ {/* Header */} +
+ +
+ Review Update: {selectedSkillData.title} +

+ Changes from v{selectedSkillData.baseVersion} to v{selectedSkillData.version} +

+
+
+ + {/* Diff */} +
+ +
+ + {/* Action buttons */} +
+ + + +
+
+ ) + } + + // ── Skill detail / editor view ───────────────────────────────────── + if (selectedSkill && selectedSkillData) { + return ( +
+ {/* Header */} +
+ +
+
+ {selectedSkillData.title} + +
+
+
+ {selectedSkillData.source === "override" && viewMode !== "edit" && ( + + )} + {viewMode !== "edit" ? ( + + ) : ( + + )} +
+
+ + {/* Update banner */} + {selectedSkillData.hasUpdate && viewMode !== "edit" && ( + + )} + + {/* Content */} +
+ {viewMode === "edit" ? ( +