kcg-code 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/README.md +230 -2
  2. package/bin/kcgcode.ts +13 -0
  3. package/bun-env.d.ts +19 -0
  4. package/kcg-code.config.example.json +3 -0
  5. package/package.json +84 -4
  6. package/public/logo.svg +1 -0
  7. package/public/manifest.json +18 -0
  8. package/public/sw.js +49 -0
  9. package/src/App.tsx +39 -0
  10. package/src/__tests__/manifest.test.ts +61 -0
  11. package/src/build-config.ts +9 -0
  12. package/src/cli/__tests__/daemon.test.ts +84 -0
  13. package/src/cli/__tests__/dashboard.test.ts +224 -0
  14. package/src/cli/__tests__/keep-awake.test.ts +44 -0
  15. package/src/cli/__tests__/port.test.ts +86 -0
  16. package/src/cli/args.ts +186 -0
  17. package/src/cli/attach.ts +154 -0
  18. package/src/cli/commands.ts +327 -0
  19. package/src/cli/daemon.ts +275 -0
  20. package/src/cli/dashboard.tsx +1060 -0
  21. package/src/cli/index.ts +47 -0
  22. package/src/cli/keep-awake.ts +148 -0
  23. package/src/cli/port.ts +92 -0
  24. package/src/cli/theme.ts +100 -0
  25. package/src/components/auth/AuthGate.tsx +182 -0
  26. package/src/components/auth/AvatarEditor.tsx +236 -0
  27. package/src/components/auth/LockScreen.tsx +714 -0
  28. package/src/components/auth/ProfileAvatar.tsx +60 -0
  29. package/src/components/auth/WelcomeScreen.tsx +672 -0
  30. package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
  31. package/src/components/auth/avatar-icons.tsx +114 -0
  32. package/src/components/layout/AppShell.tsx +147 -0
  33. package/src/components/layout/AppSidebar.tsx +419 -0
  34. package/src/components/layout/SearchDialog.tsx +189 -0
  35. package/src/components/layout/SidebarAccount.tsx +111 -0
  36. package/src/components/layout/SidebarSessionMenu.tsx +163 -0
  37. package/src/components/layout/ThemeToggle.tsx +67 -0
  38. package/src/components/projects/FolderBrowser.tsx +142 -0
  39. package/src/components/projects/HomeChat.tsx +287 -0
  40. package/src/components/projects/HomeRecentSessions.tsx +90 -0
  41. package/src/components/projects/NewProjectDialog.tsx +249 -0
  42. package/src/components/projects/ProjectInstructions.tsx +218 -0
  43. package/src/components/projects/ProjectRow.tsx +58 -0
  44. package/src/components/projects/ProjectSidePanel.tsx +514 -0
  45. package/src/components/sessions/AgentLogo.tsx +42 -0
  46. package/src/components/sessions/AgentModeList.tsx +265 -0
  47. package/src/components/sessions/AgentPicker.tsx +132 -0
  48. package/src/components/sessions/AssistantTurn.tsx +145 -0
  49. package/src/components/sessions/AttachmentTile.tsx +161 -0
  50. package/src/components/sessions/CodeBlock.tsx +107 -0
  51. package/src/components/sessions/ComposerActionSheet.tsx +96 -0
  52. package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
  53. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +92 -0
  54. package/src/components/sessions/ContextPanel.tsx +661 -0
  55. package/src/components/sessions/DropPlate.tsx +165 -0
  56. package/src/components/sessions/FileDropOverlay.tsx +232 -0
  57. package/src/components/sessions/FileMentionDropdown.tsx +47 -0
  58. package/src/components/sessions/ImageLightbox.tsx +173 -0
  59. package/src/components/sessions/MarkdownContent.tsx +187 -0
  60. package/src/components/sessions/Mascot.tsx +47 -0
  61. package/src/components/sessions/MessageActions.tsx +60 -0
  62. package/src/components/sessions/MessageRail.tsx +121 -0
  63. package/src/components/sessions/ModelPicker.tsx +247 -0
  64. package/src/components/sessions/ModelSearchList.tsx +231 -0
  65. package/src/components/sessions/NewSessionDialog.tsx +172 -0
  66. package/src/components/sessions/PendingAttachments.tsx +127 -0
  67. package/src/components/sessions/PromptCard.tsx +1085 -0
  68. package/src/components/sessions/PromptPanel.tsx +112 -0
  69. package/src/components/sessions/SessionComposer.tsx +641 -0
  70. package/src/components/sessions/SessionHeader.tsx +176 -0
  71. package/src/components/sessions/SessionList.tsx +503 -0
  72. package/src/components/sessions/SessionRow.tsx +237 -0
  73. package/src/components/sessions/SessionTimeline.tsx +275 -0
  74. package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
  75. package/src/components/sessions/SessionView.tsx +240 -0
  76. package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
  77. package/src/components/sessions/UserMessage.tsx +177 -0
  78. package/src/components/sessions/__tests__/ContextPanel.test.tsx +256 -0
  79. package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
  80. package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
  81. package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
  82. package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
  83. package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
  84. package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
  85. package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
  86. package/src/components/settings/AboutGroup.tsx +170 -0
  87. package/src/components/settings/AccountGroup.tsx +122 -0
  88. package/src/components/settings/DevicesGroup.tsx +151 -0
  89. package/src/components/settings/LhrRemoteAccess.tsx +257 -0
  90. package/src/components/settings/PasskeysGroup.tsx +551 -0
  91. package/src/components/settings/RemoteAccessGroup.tsx +1201 -0
  92. package/src/components/settings/SecurityGroup.tsx +566 -0
  93. package/src/components/settings/WorkspaceGroup.tsx +370 -0
  94. package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
  95. package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
  96. package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
  97. package/src/components/settings/prefs.tsx +333 -0
  98. package/src/components/skills/InstallNotifications.tsx +326 -0
  99. package/src/components/skills/InstallSkillDialog.tsx +390 -0
  100. package/src/components/skills/SkillCards.tsx +254 -0
  101. package/src/components/skills/SkillsProjectPicker.tsx +178 -0
  102. package/src/components/ui/adw-dialog.tsx +114 -0
  103. package/src/components/ui/adw-toggle-group.tsx +75 -0
  104. package/src/components/ui/alert-dialog.tsx +179 -0
  105. package/src/components/ui/alert.tsx +60 -0
  106. package/src/components/ui/badge.tsx +46 -0
  107. package/src/components/ui/bubble.tsx +125 -0
  108. package/src/components/ui/button.tsx +62 -0
  109. package/src/components/ui/card.tsx +75 -0
  110. package/src/components/ui/collapsible.tsx +19 -0
  111. package/src/components/ui/dialog.tsx +142 -0
  112. package/src/components/ui/dropdown-menu.tsx +225 -0
  113. package/src/components/ui/empty.tsx +94 -0
  114. package/src/components/ui/field.tsx +234 -0
  115. package/src/components/ui/input-group.tsx +161 -0
  116. package/src/components/ui/input.tsx +21 -0
  117. package/src/components/ui/label.tsx +19 -0
  118. package/src/components/ui/message-scroller.tsx +131 -0
  119. package/src/components/ui/message.tsx +85 -0
  120. package/src/components/ui/popover.tsx +46 -0
  121. package/src/components/ui/search-entry.tsx +70 -0
  122. package/src/components/ui/select.tsx +172 -0
  123. package/src/components/ui/separator.tsx +28 -0
  124. package/src/components/ui/sheet.tsx +138 -0
  125. package/src/components/ui/sidebar.tsx +699 -0
  126. package/src/components/ui/skeleton.tsx +15 -0
  127. package/src/components/ui/sonner.tsx +45 -0
  128. package/src/components/ui/spinner.tsx +16 -0
  129. package/src/components/ui/switch.tsx +32 -0
  130. package/src/components/ui/textarea.tsx +18 -0
  131. package/src/components/ui/tooltip.tsx +51 -0
  132. package/src/config.ts +114 -0
  133. package/src/db.ts +146 -0
  134. package/src/frontend.tsx +51 -0
  135. package/src/hooks/__tests__/useMention.test.ts +79 -0
  136. package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
  137. package/src/hooks/__tests__/useRouter.test.ts +17 -0
  138. package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
  139. package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
  140. package/src/hooks/__tests__/useTheme.test.ts +118 -0
  141. package/src/hooks/use-mobile.ts +16 -0
  142. package/src/hooks/useAgentPicker.ts +101 -0
  143. package/src/hooks/useMediaQuery.ts +49 -0
  144. package/src/hooks/useMention.ts +236 -0
  145. package/src/hooks/useProjects.ts +88 -0
  146. package/src/hooks/useResyncOnReconnect.ts +28 -0
  147. package/src/hooks/useRouter.ts +92 -0
  148. package/src/hooks/useSessionChat.ts +427 -0
  149. package/src/hooks/useSessionContext.ts +106 -0
  150. package/src/hooks/useSessionList.ts +327 -0
  151. package/src/hooks/useSidebarData.ts +108 -0
  152. package/src/hooks/useSmoothText.ts +122 -0
  153. package/src/hooks/useTheme.ts +164 -0
  154. package/src/hooks/useWebSocket.ts +196 -0
  155. package/src/index.css +27 -0
  156. package/src/index.html +14 -0
  157. package/src/index.ts +49 -0
  158. package/src/lib/__tests__/attachments.test.ts +58 -0
  159. package/src/lib/__tests__/avatar-presets.test.ts +25 -0
  160. package/src/lib/__tests__/collapsible.test.ts +101 -0
  161. package/src/lib/__tests__/composer.test.ts +118 -0
  162. package/src/lib/__tests__/highlight.test.ts +42 -0
  163. package/src/lib/__tests__/home-chat.test.ts +64 -0
  164. package/src/lib/__tests__/lock-screen.test.ts +64 -0
  165. package/src/lib/__tests__/model-picker.test.ts +205 -0
  166. package/src/lib/__tests__/onboarding.test.ts +41 -0
  167. package/src/lib/__tests__/permission-info.test.ts +61 -0
  168. package/src/lib/__tests__/project-extensions.test.ts +83 -0
  169. package/src/lib/__tests__/project-overview.test.ts +188 -0
  170. package/src/lib/__tests__/references.test.ts +121 -0
  171. package/src/lib/__tests__/routes.test.ts +134 -0
  172. package/src/lib/__tests__/session-selection.test.ts +41 -0
  173. package/src/lib/__tests__/session-summary.test.ts +115 -0
  174. package/src/lib/__tests__/session-title.test.ts +58 -0
  175. package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
  176. package/src/lib/__tests__/skill-installs.test.ts +135 -0
  177. package/src/lib/__tests__/skills-catalog.test.ts +124 -0
  178. package/src/lib/__tests__/tone.test.ts +37 -0
  179. package/src/lib/__tests__/turns-delta.test.ts +55 -0
  180. package/src/lib/agent-types.ts +49 -0
  181. package/src/lib/api.ts +306 -0
  182. package/src/lib/attachments.ts +203 -0
  183. package/src/lib/auth.ts +92 -0
  184. package/src/lib/avatar-presets.ts +31 -0
  185. package/src/lib/collapsible.ts +75 -0
  186. package/src/lib/composer.ts +89 -0
  187. package/src/lib/data-events.ts +17 -0
  188. package/src/lib/headlines.ts +30 -0
  189. package/src/lib/highlight.ts +186 -0
  190. package/src/lib/layout.ts +20 -0
  191. package/src/lib/lock-screen.ts +54 -0
  192. package/src/lib/model-picker.ts +133 -0
  193. package/src/lib/onboarding.ts +38 -0
  194. package/src/lib/passkeys.ts +113 -0
  195. package/src/lib/pending-prompt.ts +53 -0
  196. package/src/lib/permission-info.ts +101 -0
  197. package/src/lib/project-extensions.ts +136 -0
  198. package/src/lib/project-overview.ts +123 -0
  199. package/src/lib/prompts.ts +87 -0
  200. package/src/lib/references.ts +106 -0
  201. package/src/lib/routes.ts +124 -0
  202. package/src/lib/session-handoff.ts +25 -0
  203. package/src/lib/session-selection.ts +40 -0
  204. package/src/lib/session-status.ts +45 -0
  205. package/src/lib/session-summary.ts +68 -0
  206. package/src/lib/session-title.ts +63 -0
  207. package/src/lib/sidebar-groups.ts +78 -0
  208. package/src/lib/skill-installs.ts +362 -0
  209. package/src/lib/skills-catalog.ts +138 -0
  210. package/src/lib/time.ts +22 -0
  211. package/src/lib/tone.ts +80 -0
  212. package/src/lib/turns.ts +320 -0
  213. package/src/lib/utils.ts +6 -0
  214. package/src/logo.svg +31 -0
  215. package/src/pages/projects/ProjectDetailPage.tsx +131 -0
  216. package/src/pages/projects/ProjectsPage.tsx +97 -0
  217. package/src/pages/sessions/SessionPage.tsx +122 -0
  218. package/src/pages/settings/SettingsPage.tsx +366 -0
  219. package/src/pages/settings/__tests__/SettingsPage.test.ts +16 -0
  220. package/src/pages/skills/SkillsPage.tsx +745 -0
  221. package/src/paths.ts +121 -0
  222. package/src/runtime.ts +24 -0
  223. package/src/server/__tests__/app.test.ts +1044 -0
  224. package/src/server/__tests__/auth.test.ts +370 -0
  225. package/src/server/__tests__/config.test.ts +101 -0
  226. package/src/server/__tests__/db.test.ts +485 -0
  227. package/src/server/__tests__/host.test.ts +14 -0
  228. package/src/server/__tests__/tunnel-config.test.ts +38 -0
  229. package/src/server/app.ts +427 -0
  230. package/src/server/db/auth.ts +311 -0
  231. package/src/server/db/messages.ts +50 -0
  232. package/src/server/db/projects.ts +106 -0
  233. package/src/server/db/prompts.ts +78 -0
  234. package/src/server/db/rows.ts +150 -0
  235. package/src/server/db/schema.ts +221 -0
  236. package/src/server/db/sessions.ts +239 -0
  237. package/src/server/db/tunnel.ts +124 -0
  238. package/src/server/host.ts +12 -0
  239. package/src/server/result.ts +15 -0
  240. package/src/server/routes/auth-guard.ts +156 -0
  241. package/src/server/routes/auth.routes.ts +287 -0
  242. package/src/server/routes/helpers.ts +165 -0
  243. package/src/server/routes/meta.routes.ts +24 -0
  244. package/src/server/routes/passkeys.routes.ts +161 -0
  245. package/src/server/routes/projects.routes.ts +167 -0
  246. package/src/server/routes/sessions.routes.ts +197 -0
  247. package/src/server/routes/skills.routes.ts +136 -0
  248. package/src/server/routes/tunnel.routes.ts +132 -0
  249. package/src/server/routes/types.ts +35 -0
  250. package/src/server/routes/uploads.routes.ts +54 -0
  251. package/src/server/routes/workspace.routes.ts +127 -0
  252. package/src/server/services/__tests__/app-info.test.ts +18 -0
  253. package/src/server/services/__tests__/attachments.test.ts +159 -0
  254. package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
  255. package/src/server/services/__tests__/auth.test.ts +341 -0
  256. package/src/server/services/__tests__/claude-code.test.ts +609 -0
  257. package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
  258. package/src/server/services/__tests__/claude-skills.test.ts +63 -0
  259. package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
  260. package/src/server/services/__tests__/git-info.test.ts +74 -0
  261. package/src/server/services/__tests__/opencode.test.ts +320 -0
  262. package/src/server/services/__tests__/project-manager.test.ts +190 -0
  263. package/src/server/services/__tests__/sandbox.test.ts +141 -0
  264. package/src/server/services/__tests__/session-manager.test.ts +3509 -0
  265. package/src/server/services/__tests__/skill-install-jobs.test.ts +245 -0
  266. package/src/server/services/__tests__/skills-registry.test.ts +365 -0
  267. package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
  268. package/src/server/services/__tests__/tunnel.test.ts +367 -0
  269. package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
  270. package/src/server/services/__tests__/workspace.test.ts +249 -0
  271. package/src/server/services/app-info.ts +29 -0
  272. package/src/server/services/attachments.ts +193 -0
  273. package/src/server/services/auth-passkeys.ts +459 -0
  274. package/src/server/services/auth.ts +633 -0
  275. package/src/server/services/claude-code-client.ts +1093 -0
  276. package/src/server/services/claude-code-translate.ts +370 -0
  277. package/src/server/services/claude-mcp.ts +122 -0
  278. package/src/server/services/claude-skills.ts +102 -0
  279. package/src/server/services/frpc-installer.ts +183 -0
  280. package/src/server/services/git-info.ts +87 -0
  281. package/src/server/services/opencode-client.ts +714 -0
  282. package/src/server/services/opencode-server.ts +319 -0
  283. package/src/server/services/opencode-sse.ts +82 -0
  284. package/src/server/services/project-manager.ts +147 -0
  285. package/src/server/services/sandbox.ts +83 -0
  286. package/src/server/services/session-events.ts +256 -0
  287. package/src/server/services/session-manager.ts +1439 -0
  288. package/src/server/services/skill-install-jobs.ts +275 -0
  289. package/src/server/services/skills-registry.ts +480 -0
  290. package/src/server/services/tunnel-api.ts +207 -0
  291. package/src/server/services/tunnel-config.ts +45 -0
  292. package/src/server/services/tunnel-lhr.ts +387 -0
  293. package/src/server/services/tunnel.ts +633 -0
  294. package/src/server/services/turn-stream.ts +339 -0
  295. package/src/server/services/websocket-gateway.ts +374 -0
  296. package/src/server/services/workspace.ts +223 -0
  297. package/src/server/services/ws-transport.ts +121 -0
  298. package/src/types/context.ts +69 -0
  299. package/src/types/index.ts +13 -0
  300. package/src/types/message.ts +58 -0
  301. package/src/types/project.ts +16 -0
  302. package/src/types/prompt.ts +118 -0
  303. package/src/types/session.ts +52 -0
  304. package/src/types/turns.ts +47 -0
  305. package/src/ws-protocol.ts +93 -0
  306. package/styles/globals.css +311 -0
  307. package/tsconfig.json +37 -0
@@ -0,0 +1,745 @@
1
+ /**
2
+ * Halaman Skills (`/skills`) — jelajah & pasang skill dari skills.sh.
3
+ *
4
+ * - Header: judul + pemilih Project (pil "Install to"). Project terpilih =
5
+ * `?project=` (dari sidebar) > pilihan terakhir (localStorage) > satu-
6
+ * satunya Project. Pilihan memperbarui URL (`replaceState`).
7
+ * - Skill terpasang dibaca dari KEDUA agent (OpenCode & Claude Code) lalu
8
+ * digabung (`mergeInstalledSkills`); tiap kartu menampilkan logo agent
9
+ * tempat skill itu terpasang. Agent tujuan dipilih di dialog Install.
10
+ * - Dua tab memakai kolom cari yang sama, tapi query disimpan per tab:
11
+ * - Browse: hasil skills.sh (debounce, min. 2 karakter); saat kosong
12
+ * tampil chip saran kata kunci.
13
+ * - Installed: skill terpasang di Project (+ global), difilter lokal.
14
+ * - Hasil berupa grid kartu 2 kolom (1 kolom di HP).
15
+ * - Pintasan `/` memfokuskan kolom cari; Esc mengosongkannya.
16
+ * - Install -> dialog konfirmasi (audit keamanan) -> job instalasi di latar
17
+ * (`lib/skill-installs`). Progres + output CLI tampil di notifikasi global;
18
+ * kartu menunjukkan "Installing…" dan daftar terpasang dimuat ulang saat
19
+ * job sukses.
20
+ */
21
+ import { FolderPlusIcon, SearchIcon, SparklesIcon, XIcon } from "lucide-react";
22
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
23
+ import { InstallSkillDialog } from "@/components/skills/InstallSkillDialog";
24
+ import {
25
+ InstalledSkillCard,
26
+ RegistrySkillCard,
27
+ SkillCardSkeleton,
28
+ SkillGrid,
29
+ } from "@/components/skills/SkillCards";
30
+ import { SkillsProjectPicker } from "@/components/skills/SkillsProjectPicker";
31
+ import { Button } from "@/components/ui/button";
32
+ import {
33
+ Empty,
34
+ EmptyContent,
35
+ EmptyDescription,
36
+ EmptyHeader,
37
+ EmptyMedia,
38
+ EmptyTitle,
39
+ } from "@/components/ui/empty";
40
+ import { Skeleton } from "@/components/ui/skeleton";
41
+ import { Spinner } from "@/components/ui/spinner";
42
+ import { useRouter } from "@/hooks/useRouter";
43
+ import { AGENT_TYPE_LABELS, AGENT_TYPES, agentQuery } from "@/lib/agent-types";
44
+ import { ApiError, apiFetch } from "@/lib/api";
45
+ import { parseSkillsProject, projectsPath, skillsPath } from "@/lib/routes";
46
+ import {
47
+ findRunningInstall,
48
+ onInstallFinished,
49
+ revealInstall,
50
+ startInstall,
51
+ useInstalls,
52
+ } from "@/lib/skill-installs";
53
+ import {
54
+ type InstalledSkill,
55
+ installedAgentsFor,
56
+ LAST_SKILLS_PROJECT_KEY,
57
+ mergeInstalledSkills,
58
+ resolveSkillsProject,
59
+ SEARCH_SUGGESTIONS,
60
+ visibleInstalledSkills,
61
+ } from "@/lib/skills-catalog";
62
+ import { cn } from "@/lib/utils";
63
+ import type { SkillInfo } from "@/server/services/opencode-client";
64
+ import type { RegistrySkill } from "@/server/services/skills-registry";
65
+ import type { AgentType, Project } from "@/types";
66
+
67
+ const EMPTY_INSTALLED: InstalledSkill[] = [];
68
+
69
+ /** Jeda ketik sebelum memanggil skills.sh. */
70
+ const SEARCH_DEBOUNCE_MS = 350;
71
+ const MIN_QUERY = 2;
72
+
73
+ type Tab = "browse" | "installed";
74
+
75
+ type Load<T> =
76
+ | { phase: "idle" }
77
+ | { phase: "loading" }
78
+ | { phase: "error"; message: string }
79
+ | { phase: "ready"; data: T };
80
+
81
+ function errorMessage(e: unknown, fallback: string): string {
82
+ return e instanceof ApiError ? e.message : fallback;
83
+ }
84
+
85
+ function readStoredProject(): string | null {
86
+ try {
87
+ return localStorage.getItem(LAST_SKILLS_PROJECT_KEY);
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ function storeProject(id: string): void {
94
+ try {
95
+ localStorage.setItem(LAST_SKILLS_PROJECT_KEY, id);
96
+ } catch {
97
+ // Storage tidak tersedia (mode privat) — cukup URL.
98
+ }
99
+ }
100
+
101
+ export function SkillsPage() {
102
+ const { pathname, navigate } = useRouter();
103
+ const [projects, setProjects] = useState<Project[] | null>(null);
104
+ const [projectsError, setProjectsError] = useState<string | null>(null);
105
+ const [urlProjectId, setUrlProjectId] = useState<string | null>(() =>
106
+ parseSkillsProject(window.location.search),
107
+ );
108
+
109
+ // Sinkron dengan URL saat navigasi SPA ke /skills?project=... dari sidebar.
110
+ // biome-ignore lint/correctness/useExhaustiveDependencies: pathname jadi trigger navigasi
111
+ useEffect(() => {
112
+ setUrlProjectId(parseSkillsProject(window.location.search));
113
+ }, [pathname]);
114
+
115
+ useEffect(() => {
116
+ let cancelled = false;
117
+ apiFetch("/api/projects")
118
+ .then((res) => res.json() as Promise<{ projects: Project[] }>)
119
+ .then(({ projects }) => {
120
+ if (!cancelled) setProjects(projects);
121
+ })
122
+ .catch((e) => {
123
+ if (!cancelled) {
124
+ setProjects([]);
125
+ setProjectsError(errorMessage(e, "Failed to load projects"));
126
+ }
127
+ });
128
+ return () => {
129
+ cancelled = true;
130
+ };
131
+ }, []);
132
+
133
+ const selected = useMemo(
134
+ () => (projects ? resolveSkillsProject(projects, urlProjectId, readStoredProject()) : null),
135
+ [projects, urlProjectId],
136
+ );
137
+
138
+ // Pilihan hasil fallback (tersimpan / satu-satunya) ikut ditulis ke URL.
139
+ useEffect(() => {
140
+ if (selected && selected.id !== urlProjectId) {
141
+ window.history.replaceState({}, "", skillsPath(selected.id));
142
+ setUrlProjectId(selected.id);
143
+ }
144
+ }, [selected, urlProjectId]);
145
+
146
+ const choose = useCallback((id: string) => {
147
+ storeProject(id);
148
+ setUrlProjectId(id);
149
+ window.history.replaceState({}, "", skillsPath(id));
150
+ }, []);
151
+
152
+ return (
153
+ <div className="flex flex-col gap-6">
154
+ {/* Header: judul di kiri, tujuan instalasi (pil) di kanan — konteks
155
+ halaman, terpisah jelas dari kolom cari di bawah tab. */}
156
+ <header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
157
+ <div className="flex min-w-0 flex-col gap-1">
158
+ <h1 className="text-2xl font-semibold tracking-tight">Skills</h1>
159
+ <p className="text-sm text-muted-foreground">
160
+ Discover skills on{" "}
161
+ <a
162
+ href="https://skills.sh"
163
+ target="_blank"
164
+ rel="noopener noreferrer"
165
+ className="font-medium text-foreground underline underline-offset-4"
166
+ >
167
+ skills.sh
168
+ <span className="sr-only"> (opens in a new tab)</span>
169
+ </a>{" "}
170
+ and add them to your project's agent.
171
+ </p>
172
+ </div>
173
+ {projects === null ? (
174
+ <Skeleton className="h-9 w-48 shrink-0 rounded-full" />
175
+ ) : (
176
+ projects.length > 0 && (
177
+ <div className="min-w-0 shrink-0">
178
+ <SkillsProjectPicker projects={projects} selected={selected} onSelect={choose} />
179
+ </div>
180
+ )
181
+ )}
182
+ </header>
183
+
184
+ {projects === null ? (
185
+ <div className="flex flex-col gap-4">
186
+ <Skeleton className="h-10 w-48" />
187
+ <Skeleton className="h-11 w-full rounded-xl" />
188
+ </div>
189
+ ) : projects.length === 0 ? (
190
+ <Empty className="border">
191
+ <EmptyHeader>
192
+ <EmptyMedia variant="icon">
193
+ <FolderPlusIcon />
194
+ </EmptyMedia>
195
+ <EmptyTitle>No projects yet</EmptyTitle>
196
+ <EmptyDescription>
197
+ {projectsError ?? "Skills are installed per project. Create a project first."}
198
+ </EmptyDescription>
199
+ </EmptyHeader>
200
+ <EmptyContent>
201
+ <Button type="button" onClick={() => navigate(projectsPath())}>
202
+ Go to projects
203
+ </Button>
204
+ </EmptyContent>
205
+ </Empty>
206
+ ) : (
207
+ // Remount per Project: pencarian & daftar terpasang tidak bocor.
208
+ <SkillsWorkspace key={selected?.id ?? "none"} project={selected} />
209
+ )}
210
+ </div>
211
+ );
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+
216
+ interface SkillsWorkspaceProps {
217
+ project: Project | null;
218
+ }
219
+
220
+ function SkillsWorkspace({ project }: SkillsWorkspaceProps) {
221
+ const [tab, setTab] = useState<Tab>("browse");
222
+ /**
223
+ * Query per tab: Browse mencari skills.sh (remote), Installed memfilter
224
+ * lokal — memakai satu query bersama membuat tab lain ikut terfilter.
225
+ */
226
+ const [queries, setQueries] = useState<Record<Tab, string>>({ browse: "", installed: "" });
227
+ const query = queries[tab];
228
+ const setQuery = useCallback((q: string) => setQueries((prev) => ({ ...prev, [tab]: q })), [tab]);
229
+ const [installed, setInstalled] = useState<Load<InstalledSkill[]>>({ phase: "idle" });
230
+ /** Sebagian agent gagal dibaca (daftar tetap tampil dari agent lain). */
231
+ const [installedWarning, setInstalledWarning] = useState<string | null>(null);
232
+ const [reloadKey, setReloadKey] = useState(0);
233
+ const [results, setResults] = useState<Load<RegistrySkill[]>>({ phase: "idle" });
234
+ /** Permintaan pencarian sedang berjalan (hasil lama tetap tampil). */
235
+ const [searchBusy, setSearchBusy] = useState(false);
236
+ const [pending, setPending] = useState<RegistrySkill | null>(null);
237
+ /** Permintaan memulai instalasi sedang dikirim (job belum dibuat). */
238
+ const [starting, setStarting] = useState(false);
239
+ const [startError, setStartError] = useState<string | null>(null);
240
+ const installs = useInstalls();
241
+ const searchRef = useRef<HTMLInputElement>(null);
242
+
243
+ // Pintasan `/` memfokuskan kolom cari (kecuali sedang mengetik di input lain).
244
+ useEffect(() => {
245
+ const onKey = (e: KeyboardEvent) => {
246
+ if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
247
+ const t = e.target as HTMLElement | null;
248
+ if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
249
+ e.preventDefault();
250
+ searchRef.current?.focus();
251
+ };
252
+ window.addEventListener("keydown", onKey);
253
+ return () => window.removeEventListener("keydown", onKey);
254
+ }, []);
255
+
256
+ // Skill terpasang dari semua agent (opencode: server headless Project;
257
+ // Claude Code: `.claude/skills` Project + global), dibaca bersamaan lalu
258
+ // digabung. Satu agent gagal tidak menggagalkan yang lain.
259
+ useEffect(() => {
260
+ void reloadKey;
261
+ if (!project) return;
262
+ let cancelled = false;
263
+ setInstalled((prev) => (prev.phase === "ready" ? prev : { phase: "loading" }));
264
+ void Promise.allSettled(
265
+ AGENT_TYPES.map((a) =>
266
+ apiFetch(`/api/projects/${project.id}/skills${agentQuery(a)}`)
267
+ .then((res) => res.json() as Promise<{ skills: SkillInfo[] }>)
268
+ .then(({ skills }) => skills),
269
+ ),
270
+ ).then((results) => {
271
+ if (cancelled) return;
272
+ const byAgent: Partial<Record<AgentType, SkillInfo[]>> = {};
273
+ const failed: { agent: AgentType; message: string }[] = [];
274
+ results.forEach((r, i) => {
275
+ const agent = AGENT_TYPES[i] as AgentType;
276
+ if (r.status === "fulfilled") byAgent[agent] = r.value;
277
+ else failed.push({ agent, message: errorMessage(r.reason, "Failed to load skills") });
278
+ });
279
+ if (failed.length === AGENT_TYPES.length) {
280
+ setInstalled({ phase: "error", message: failed[0]?.message ?? "Failed to load skills" });
281
+ setInstalledWarning(null);
282
+ return;
283
+ }
284
+ setInstalled({ phase: "ready", data: mergeInstalledSkills(byAgent) });
285
+ setInstalledWarning(
286
+ failed.length > 0
287
+ ? `Couldn't read ${failed.map((f) => AGENT_TYPE_LABELS[f.agent]).join(", ")} skills: ${failed[0]?.message}`
288
+ : null,
289
+ );
290
+ });
291
+ return () => {
292
+ cancelled = true;
293
+ };
294
+ }, [project, reloadKey]);
295
+
296
+ // Pencarian skills.sh dengan debounce; respon usang dibuang. Hasil lama
297
+ // tetap tampil selama query baru dimuat (spinner di kolom cari).
298
+ const trimmed = query.trim();
299
+ const browseQuery = queries.browse.trim();
300
+ const canSearch = browseQuery.length >= MIN_QUERY;
301
+ useEffect(() => {
302
+ if (!canSearch) {
303
+ setResults({ phase: "idle" });
304
+ setSearchBusy(false);
305
+ return;
306
+ }
307
+ let cancelled = false;
308
+ setResults((prev) => (prev.phase === "ready" ? prev : { phase: "loading" }));
309
+ setSearchBusy(true);
310
+ const timer = setTimeout(() => {
311
+ apiFetch(`/api/skills/search?${new URLSearchParams({ q: browseQuery })}`)
312
+ .then((res) => res.json() as Promise<{ skills: RegistrySkill[] }>)
313
+ .then(({ skills }) => {
314
+ if (!cancelled) setResults({ phase: "ready", data: skills });
315
+ })
316
+ .catch((e) => {
317
+ if (!cancelled) setResults({ phase: "error", message: errorMessage(e, "Search failed") });
318
+ })
319
+ .finally(() => {
320
+ if (!cancelled) setSearchBusy(false);
321
+ });
322
+ }, SEARCH_DEBOUNCE_MS);
323
+ return () => {
324
+ cancelled = true;
325
+ clearTimeout(timer);
326
+ };
327
+ }, [browseQuery, canSearch]);
328
+
329
+ const installedList = installed.phase === "ready" ? installed.data : EMPTY_INSTALLED;
330
+ const installedAll = useMemo(
331
+ () => (installed.phase === "ready" ? visibleInstalledSkills(installed.data, "") : []),
332
+ [installed],
333
+ );
334
+ const installedFiltered = useMemo(
335
+ () =>
336
+ installed.phase === "ready" ? visibleInstalledSkills(installed.data, queries.installed) : [],
337
+ [installed, queries.installed],
338
+ );
339
+
340
+ // Instalasi (di halaman mana pun) selesai untuk Project ini -> muat ulang
341
+ // daftar terpasang agar kartu berubah menjadi "Installed".
342
+ useEffect(() => {
343
+ if (!project) return;
344
+ return onInstallFinished((job) => {
345
+ if (job.projectId === project.id && job.status === "succeeded") {
346
+ setReloadKey((k) => k + 1);
347
+ }
348
+ });
349
+ }, [project]);
350
+
351
+ /** Mulai job instalasi; progres & output tampil di notifikasi global. */
352
+ const install = async (skill: RegistrySkill, agents: AgentType[]) => {
353
+ if (!project) return;
354
+ setStarting(true);
355
+ setStartError(null);
356
+ try {
357
+ await startInstall(project.id, skill.source, skill.skillId, agents);
358
+ setPending(null);
359
+ } catch (e) {
360
+ // Dialog tetap terbuka agar user melihat alasannya.
361
+ setStartError(errorMessage(e, "Failed to start the installation."));
362
+ } finally {
363
+ setStarting(false);
364
+ }
365
+ };
366
+
367
+ const placeholder =
368
+ tab === "browse" ? "Search skills.sh — e.g. react, testing, docs" : "Filter installed skills";
369
+
370
+ return (
371
+ <div className="flex flex-col gap-5">
372
+ {!project ? (
373
+ <Empty className="border">
374
+ <EmptyHeader>
375
+ <EmptyMedia variant="icon">
376
+ <SparklesIcon />
377
+ </EmptyMedia>
378
+ <EmptyTitle>Choose where to install</EmptyTitle>
379
+ <EmptyDescription>
380
+ Skills are added per project. Pick a project in the top right to browse and install
381
+ skills.
382
+ </EmptyDescription>
383
+ </EmptyHeader>
384
+ </Empty>
385
+ ) : (
386
+ <>
387
+ {/* Tabs, lalu kolom cari untuk tab aktif (Browse = skills.sh,
388
+ Installed = filter lokal). */}
389
+ <div className="flex flex-col gap-4">
390
+ <div
391
+ role="tablist"
392
+ aria-label="Skill views"
393
+ className="flex items-center gap-1 border-b"
394
+ >
395
+ <TabButton id="browse" current={tab} onSelect={setTab}>
396
+ Browse
397
+ </TabButton>
398
+ <TabButton id="installed" current={tab} onSelect={setTab}>
399
+ Installed
400
+ {installed.phase === "ready" && (
401
+ <span className="rounded-full bg-muted px-1.5 text-[11px] leading-4 tabular-nums text-muted-foreground">
402
+ {installedAll.length}
403
+ </span>
404
+ )}
405
+ </TabButton>
406
+ </div>
407
+ <div className="relative flex min-w-0 items-center">
408
+ <SearchIcon
409
+ className="pointer-events-none absolute left-3.5 size-4 text-muted-foreground"
410
+ aria-hidden
411
+ />
412
+ <input
413
+ ref={searchRef}
414
+ type="search"
415
+ value={query}
416
+ onChange={(e) => setQuery(e.target.value)}
417
+ onKeyDown={(e) => {
418
+ if (e.key === "Escape" && query !== "") {
419
+ e.preventDefault();
420
+ setQuery("");
421
+ }
422
+ }}
423
+ placeholder={placeholder}
424
+ aria-label={
425
+ tab === "browse" ? "Search skills on skills.sh" : "Filter installed skills"
426
+ }
427
+ aria-describedby="skills-search-hint"
428
+ className="h-11 w-full rounded-xl border bg-card pr-20 pl-10 text-sm shadow-xs outline-none transition-shadow placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60 [&::-webkit-search-cancel-button]:hidden"
429
+ />
430
+ <div className="absolute right-2 flex items-center gap-1">
431
+ {searchBusy && tab === "browse" && (
432
+ <Spinner className="size-4 text-muted-foreground" />
433
+ )}
434
+ {query !== "" ? (
435
+ <button
436
+ type="button"
437
+ onClick={() => {
438
+ setQuery("");
439
+ searchRef.current?.focus();
440
+ }}
441
+ aria-label="Clear search"
442
+ className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
443
+ >
444
+ <XIcon className="size-4" />
445
+ </button>
446
+ ) : (
447
+ <kbd
448
+ className="hidden rounded border bg-muted px-1.5 font-mono text-[11px] text-muted-foreground sm:inline"
449
+ aria-hidden
450
+ >
451
+ /
452
+ </kbd>
453
+ )}
454
+ </div>
455
+ <span id="skills-search-hint" className="sr-only">
456
+ Press slash to focus search, Escape to clear.
457
+ </span>
458
+ </div>
459
+ </div>
460
+
461
+ <div role="tabpanel" id={`skills-panel-${tab}`} aria-labelledby={`skills-tab-${tab}`}>
462
+ {tab === "browse" ? (
463
+ <BrowsePanel
464
+ query={trimmed}
465
+ canSearch={canSearch}
466
+ results={results}
467
+ installedAgents={(skill) => installedAgentsFor(skill, installedList)}
468
+ isInstalling={(skill) =>
469
+ findRunningInstall(installs, project.id, skill.source, skill.skillId)?.job.id ??
470
+ null
471
+ }
472
+ onSuggest={(q) => {
473
+ setQuery(q);
474
+ searchRef.current?.focus();
475
+ }}
476
+ onInstall={setPending}
477
+ />
478
+ ) : (
479
+ <InstalledPanel
480
+ state={installed}
481
+ warning={installedWarning}
482
+ skills={installedFiltered}
483
+ total={installedAll.length}
484
+ query={trimmed}
485
+ onBrowse={() => {
486
+ setTab("browse");
487
+ searchRef.current?.focus();
488
+ }}
489
+ />
490
+ )}
491
+ </div>
492
+
493
+ <InstallSkillDialog
494
+ skill={pending}
495
+ projectName={project.name}
496
+ installedAgents={pending ? installedAgentsFor(pending, installedList) : []}
497
+ starting={starting}
498
+ startError={startError}
499
+ onOpenChange={(open) => {
500
+ if (!open) {
501
+ setPending(null);
502
+ setStartError(null);
503
+ }
504
+ }}
505
+ onConfirm={(s, agents) => void install(s, agents)}
506
+ />
507
+ </>
508
+ )}
509
+ </div>
510
+ );
511
+ }
512
+
513
+ function TabButton({
514
+ id,
515
+ current,
516
+ onSelect,
517
+ children,
518
+ }: {
519
+ id: Tab;
520
+ current: Tab;
521
+ onSelect: (tab: Tab) => void;
522
+ children: React.ReactNode;
523
+ }) {
524
+ const active = id === current;
525
+ return (
526
+ <button
527
+ type="button"
528
+ role="tab"
529
+ id={`skills-tab-${id}`}
530
+ aria-selected={active}
531
+ aria-controls={`skills-panel-${id}`}
532
+ tabIndex={active ? 0 : -1}
533
+ onClick={() => onSelect(id)}
534
+ onKeyDown={(e) => {
535
+ if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
536
+ e.preventDefault();
537
+ const next: Tab = id === "browse" ? "installed" : "browse";
538
+ onSelect(next);
539
+ document.getElementById(`skills-tab-${next}`)?.focus();
540
+ }
541
+ }}
542
+ className={cn(
543
+ "-mb-px flex h-10 items-center gap-1.5 border-b-2 px-3 text-sm font-medium transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
544
+ active
545
+ ? "border-foreground text-foreground"
546
+ : "border-transparent text-muted-foreground hover:text-foreground",
547
+ )}
548
+ >
549
+ {children}
550
+ </button>
551
+ );
552
+ }
553
+
554
+ // ---------------------------------------------------------------------------
555
+
556
+ function BrowsePanel({
557
+ query,
558
+ canSearch,
559
+ results,
560
+ installedAgents,
561
+ isInstalling,
562
+ onSuggest,
563
+ onInstall,
564
+ }: {
565
+ query: string;
566
+ canSearch: boolean;
567
+ results: Load<RegistrySkill[]>;
568
+ /** Agent yang sudah memuat skill katalog ini dari folder Project. */
569
+ installedAgents: (skill: RegistrySkill) => AgentType[];
570
+ /** Id job instalasi berjalan untuk skill ini (null bila tidak ada). */
571
+ isInstalling: (skill: RegistrySkill) => string | null;
572
+ onSuggest: (q: string) => void;
573
+ onInstall: (skill: RegistrySkill) => void;
574
+ }) {
575
+ if (!canSearch) {
576
+ return (
577
+ <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed px-6 py-10 text-center">
578
+ <span className="flex size-11 items-center justify-center rounded-xl bg-muted text-muted-foreground">
579
+ <SearchIcon className="size-5" aria-hidden />
580
+ </span>
581
+ <div className="flex flex-col gap-1">
582
+ <p className="text-sm font-medium">
583
+ {query.length === 1 ? "Keep typing…" : "Search the skills.sh directory"}
584
+ </p>
585
+ <p className="text-sm text-muted-foreground">
586
+ Type at least 2 characters, or start with a popular topic.
587
+ </p>
588
+ </div>
589
+ <div className="flex flex-wrap justify-center gap-2">
590
+ {SEARCH_SUGGESTIONS.map((s) => (
591
+ <button
592
+ key={s}
593
+ type="button"
594
+ onClick={() => onSuggest(s)}
595
+ className="rounded-full border bg-card px-3 py-1 text-xs font-medium text-muted-foreground transition-colors hover:border-foreground/30 hover:text-foreground"
596
+ >
597
+ {s}
598
+ </button>
599
+ ))}
600
+ </div>
601
+ </div>
602
+ );
603
+ }
604
+
605
+ if (results.phase === "idle" || results.phase === "loading") return <SkillCardSkeleton />;
606
+ if (results.phase === "error") {
607
+ return (
608
+ <p
609
+ role="alert"
610
+ className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive"
611
+ >
612
+ {results.message}
613
+ </p>
614
+ );
615
+ }
616
+ if (results.data.length === 0) {
617
+ return (
618
+ <Empty className="border">
619
+ <EmptyHeader>
620
+ <EmptyMedia variant="icon">
621
+ <SearchIcon />
622
+ </EmptyMedia>
623
+ <EmptyTitle>No skills found</EmptyTitle>
624
+ <EmptyDescription>
625
+ Nothing on skills.sh matches “{query}”. Try a broader keyword.
626
+ </EmptyDescription>
627
+ </EmptyHeader>
628
+ </Empty>
629
+ );
630
+ }
631
+
632
+ return (
633
+ <section aria-label="Search results" className="flex flex-col gap-3">
634
+ <p className="text-xs text-muted-foreground" aria-live="polite">
635
+ {results.data.length} {results.data.length === 1 ? "result" : "results"} for “{query}” ·
636
+ sorted by installs
637
+ </p>
638
+ <SkillGrid label="Skills on skills.sh">
639
+ {results.data.map((skill) => {
640
+ const jobId = isInstalling(skill);
641
+ return (
642
+ <RegistrySkillCard
643
+ key={skill.id}
644
+ skill={skill}
645
+ installedAgents={installedAgents(skill)}
646
+ installing={jobId !== null}
647
+ onInstall={onInstall}
648
+ onShowProgress={() => {
649
+ if (jobId) revealInstall(jobId);
650
+ }}
651
+ />
652
+ );
653
+ })}
654
+ </SkillGrid>
655
+ </section>
656
+ );
657
+ }
658
+
659
+ function InstalledPanel({
660
+ state,
661
+ warning,
662
+ skills,
663
+ total,
664
+ query,
665
+ onBrowse,
666
+ }: {
667
+ state: Load<InstalledSkill[]>;
668
+ /** Sebagian agent gagal dibaca. */
669
+ warning: string | null;
670
+ skills: InstalledSkill[];
671
+ total: number;
672
+ query: string;
673
+ onBrowse: () => void;
674
+ }) {
675
+ if (state.phase === "idle" || state.phase === "loading") return <SkillCardSkeleton />;
676
+ if (state.phase === "error") {
677
+ return (
678
+ <p
679
+ role="alert"
680
+ className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive"
681
+ >
682
+ {state.message}
683
+ </p>
684
+ );
685
+ }
686
+
687
+ const warningNote = warning && (
688
+ <p role="status" className="mb-3 text-xs text-muted-foreground">
689
+ {warning}
690
+ </p>
691
+ );
692
+
693
+ if (total === 0) {
694
+ return (
695
+ <>
696
+ {warningNote}
697
+ <Empty className="border">
698
+ <EmptyHeader>
699
+ <EmptyMedia variant="icon">
700
+ <SparklesIcon />
701
+ </EmptyMedia>
702
+ <EmptyTitle>No skills installed yet</EmptyTitle>
703
+ <EmptyDescription>
704
+ Browse skills.sh to add your first skill to this project.
705
+ </EmptyDescription>
706
+ </EmptyHeader>
707
+ <EmptyContent>
708
+ <Button type="button" variant="outline" onClick={onBrowse}>
709
+ <SearchIcon data-icon="inline-start" />
710
+ Browse skills
711
+ </Button>
712
+ </EmptyContent>
713
+ </Empty>
714
+ </>
715
+ );
716
+ }
717
+
718
+ if (skills.length === 0) {
719
+ return (
720
+ <p className="py-10 text-center text-sm text-muted-foreground">
721
+ No installed skills match “{query}”.
722
+ </p>
723
+ );
724
+ }
725
+
726
+ return (
727
+ <section aria-label="Installed skills" className="flex flex-col gap-3">
728
+ {warning && (
729
+ <p role="status" className="text-xs text-muted-foreground">
730
+ {warning}
731
+ </p>
732
+ )}
733
+ {query !== "" && (
734
+ <p className="text-xs text-muted-foreground" aria-live="polite">
735
+ {skills.length} of {total} installed skills
736
+ </p>
737
+ )}
738
+ <SkillGrid label="Installed skills">
739
+ {skills.map((skill) => (
740
+ <InstalledSkillCard key={`${skill.source}:${skill.name}`} skill={skill} />
741
+ ))}
742
+ </SkillGrid>
743
+ </section>
744
+ );
745
+ }