kcg-code 0.0.0-stage → 0.1.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 (286) hide show
  1. package/README.md +228 -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 +83 -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__/dashboard.test.ts +62 -0
  13. package/src/cli/args.ts +123 -0
  14. package/src/cli/commands.ts +174 -0
  15. package/src/cli/dashboard.tsx +343 -0
  16. package/src/cli/index.ts +37 -0
  17. package/src/cli/theme.ts +100 -0
  18. package/src/components/auth/AuthGate.tsx +182 -0
  19. package/src/components/auth/AvatarEditor.tsx +236 -0
  20. package/src/components/auth/LockScreen.tsx +714 -0
  21. package/src/components/auth/ProfileAvatar.tsx +60 -0
  22. package/src/components/auth/WelcomeScreen.tsx +672 -0
  23. package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
  24. package/src/components/auth/avatar-icons.tsx +114 -0
  25. package/src/components/layout/AppShell.tsx +142 -0
  26. package/src/components/layout/AppSidebar.tsx +419 -0
  27. package/src/components/layout/SearchDialog.tsx +189 -0
  28. package/src/components/layout/SidebarAccount.tsx +111 -0
  29. package/src/components/layout/SidebarSessionMenu.tsx +163 -0
  30. package/src/components/layout/ThemeToggle.tsx +67 -0
  31. package/src/components/projects/FolderBrowser.tsx +142 -0
  32. package/src/components/projects/HomeChat.tsx +250 -0
  33. package/src/components/projects/NewProjectDialog.tsx +249 -0
  34. package/src/components/projects/PanelSection.tsx +102 -0
  35. package/src/components/projects/ProjectInstructions.tsx +229 -0
  36. package/src/components/projects/ProjectRow.tsx +58 -0
  37. package/src/components/projects/ProjectSidePanel.tsx +448 -0
  38. package/src/components/sessions/AgentModeList.tsx +265 -0
  39. package/src/components/sessions/AgentPicker.tsx +123 -0
  40. package/src/components/sessions/AssistantTurn.tsx +145 -0
  41. package/src/components/sessions/AttachmentTile.tsx +161 -0
  42. package/src/components/sessions/CodeBlock.tsx +107 -0
  43. package/src/components/sessions/ComposerActionSheet.tsx +96 -0
  44. package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
  45. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +72 -0
  46. package/src/components/sessions/ContextPanel.tsx +561 -0
  47. package/src/components/sessions/DropPlate.tsx +165 -0
  48. package/src/components/sessions/FileDropOverlay.tsx +232 -0
  49. package/src/components/sessions/FileMentionDropdown.tsx +47 -0
  50. package/src/components/sessions/ImageLightbox.tsx +173 -0
  51. package/src/components/sessions/MarkdownContent.tsx +187 -0
  52. package/src/components/sessions/Mascot.tsx +47 -0
  53. package/src/components/sessions/MessageActions.tsx +60 -0
  54. package/src/components/sessions/MessageRail.tsx +121 -0
  55. package/src/components/sessions/ModelPicker.tsx +209 -0
  56. package/src/components/sessions/ModelSearchList.tsx +151 -0
  57. package/src/components/sessions/NewSessionDialog.tsx +189 -0
  58. package/src/components/sessions/PendingAttachments.tsx +127 -0
  59. package/src/components/sessions/PromptCard.tsx +1085 -0
  60. package/src/components/sessions/PromptPanel.tsx +112 -0
  61. package/src/components/sessions/SessionComposer.tsx +640 -0
  62. package/src/components/sessions/SessionHeader.tsx +175 -0
  63. package/src/components/sessions/SessionList.tsx +396 -0
  64. package/src/components/sessions/SessionRow.tsx +137 -0
  65. package/src/components/sessions/SessionTimeline.tsx +275 -0
  66. package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
  67. package/src/components/sessions/SessionView.tsx +236 -0
  68. package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
  69. package/src/components/sessions/UserMessage.tsx +177 -0
  70. package/src/components/sessions/__tests__/ContextPanel.test.tsx +220 -0
  71. package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
  72. package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
  73. package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
  74. package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
  75. package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
  76. package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
  77. package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
  78. package/src/components/settings/AboutGroup.tsx +170 -0
  79. package/src/components/settings/AccountGroup.tsx +122 -0
  80. package/src/components/settings/DevicesGroup.tsx +151 -0
  81. package/src/components/settings/LhrRemoteAccess.tsx +262 -0
  82. package/src/components/settings/PasskeysGroup.tsx +551 -0
  83. package/src/components/settings/RemoteAccessGroup.tsx +1226 -0
  84. package/src/components/settings/SecurityGroup.tsx +566 -0
  85. package/src/components/settings/WorkspaceGroup.tsx +370 -0
  86. package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
  87. package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
  88. package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
  89. package/src/components/settings/prefs.tsx +265 -0
  90. package/src/components/skills/InstallNotifications.tsx +325 -0
  91. package/src/components/skills/InstallSkillDialog.tsx +233 -0
  92. package/src/components/skills/SkillCards.tsx +210 -0
  93. package/src/components/skills/SkillsProjectPicker.tsx +178 -0
  94. package/src/components/ui/alert-dialog.tsx +179 -0
  95. package/src/components/ui/alert.tsx +60 -0
  96. package/src/components/ui/badge.tsx +46 -0
  97. package/src/components/ui/bubble.tsx +125 -0
  98. package/src/components/ui/button.tsx +62 -0
  99. package/src/components/ui/card.tsx +75 -0
  100. package/src/components/ui/collapsible.tsx +19 -0
  101. package/src/components/ui/dialog.tsx +142 -0
  102. package/src/components/ui/dropdown-menu.tsx +225 -0
  103. package/src/components/ui/empty.tsx +94 -0
  104. package/src/components/ui/field.tsx +234 -0
  105. package/src/components/ui/input-group.tsx +161 -0
  106. package/src/components/ui/input.tsx +21 -0
  107. package/src/components/ui/label.tsx +19 -0
  108. package/src/components/ui/message-scroller.tsx +131 -0
  109. package/src/components/ui/message.tsx +85 -0
  110. package/src/components/ui/popover.tsx +46 -0
  111. package/src/components/ui/select.tsx +172 -0
  112. package/src/components/ui/separator.tsx +28 -0
  113. package/src/components/ui/sheet.tsx +138 -0
  114. package/src/components/ui/sidebar.tsx +699 -0
  115. package/src/components/ui/skeleton.tsx +15 -0
  116. package/src/components/ui/sonner.tsx +45 -0
  117. package/src/components/ui/spinner.tsx +16 -0
  118. package/src/components/ui/switch.tsx +32 -0
  119. package/src/components/ui/textarea.tsx +18 -0
  120. package/src/components/ui/tooltip.tsx +51 -0
  121. package/src/config.ts +85 -0
  122. package/src/db.ts +146 -0
  123. package/src/frontend.tsx +51 -0
  124. package/src/hooks/__tests__/useMention.test.ts +79 -0
  125. package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
  126. package/src/hooks/__tests__/useRouter.test.ts +17 -0
  127. package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
  128. package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
  129. package/src/hooks/__tests__/useTheme.test.ts +118 -0
  130. package/src/hooks/use-mobile.ts +16 -0
  131. package/src/hooks/useAgentPicker.ts +97 -0
  132. package/src/hooks/useMediaQuery.ts +49 -0
  133. package/src/hooks/useMention.ts +236 -0
  134. package/src/hooks/useProjects.ts +88 -0
  135. package/src/hooks/useResyncOnReconnect.ts +28 -0
  136. package/src/hooks/useRouter.ts +92 -0
  137. package/src/hooks/useSessionChat.ts +427 -0
  138. package/src/hooks/useSessionContext.ts +106 -0
  139. package/src/hooks/useSessionList.ts +234 -0
  140. package/src/hooks/useSidebarData.ts +108 -0
  141. package/src/hooks/useSmoothText.ts +122 -0
  142. package/src/hooks/useTheme.ts +164 -0
  143. package/src/hooks/useWebSocket.ts +196 -0
  144. package/src/index.css +27 -0
  145. package/src/index.html +14 -0
  146. package/src/index.ts +49 -0
  147. package/src/lib/__tests__/attachments.test.ts +58 -0
  148. package/src/lib/__tests__/avatar-presets.test.ts +25 -0
  149. package/src/lib/__tests__/collapsible.test.ts +101 -0
  150. package/src/lib/__tests__/composer.test.ts +118 -0
  151. package/src/lib/__tests__/highlight.test.ts +42 -0
  152. package/src/lib/__tests__/home-chat.test.ts +64 -0
  153. package/src/lib/__tests__/lock-screen.test.ts +64 -0
  154. package/src/lib/__tests__/model-picker.test.ts +205 -0
  155. package/src/lib/__tests__/onboarding.test.ts +41 -0
  156. package/src/lib/__tests__/permission-info.test.ts +61 -0
  157. package/src/lib/__tests__/project-extensions.test.ts +52 -0
  158. package/src/lib/__tests__/project-overview.test.ts +173 -0
  159. package/src/lib/__tests__/references.test.ts +121 -0
  160. package/src/lib/__tests__/routes.test.ts +134 -0
  161. package/src/lib/__tests__/session-summary.test.ts +115 -0
  162. package/src/lib/__tests__/session-title.test.ts +58 -0
  163. package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
  164. package/src/lib/__tests__/skill-installs.test.ts +134 -0
  165. package/src/lib/__tests__/skills-catalog.test.ts +96 -0
  166. package/src/lib/__tests__/tone.test.ts +37 -0
  167. package/src/lib/__tests__/turns-delta.test.ts +55 -0
  168. package/src/lib/api.ts +306 -0
  169. package/src/lib/attachments.ts +203 -0
  170. package/src/lib/auth.ts +92 -0
  171. package/src/lib/avatar-presets.ts +31 -0
  172. package/src/lib/collapsible.ts +75 -0
  173. package/src/lib/composer.ts +89 -0
  174. package/src/lib/data-events.ts +17 -0
  175. package/src/lib/headlines.ts +30 -0
  176. package/src/lib/highlight.ts +186 -0
  177. package/src/lib/layout.ts +20 -0
  178. package/src/lib/lock-screen.ts +54 -0
  179. package/src/lib/model-picker.ts +133 -0
  180. package/src/lib/onboarding.ts +38 -0
  181. package/src/lib/passkeys.ts +113 -0
  182. package/src/lib/pending-prompt.ts +53 -0
  183. package/src/lib/permission-info.ts +101 -0
  184. package/src/lib/project-extensions.ts +74 -0
  185. package/src/lib/project-overview.ts +117 -0
  186. package/src/lib/prompts.ts +87 -0
  187. package/src/lib/references.ts +106 -0
  188. package/src/lib/routes.ts +115 -0
  189. package/src/lib/session-handoff.ts +25 -0
  190. package/src/lib/session-status.ts +45 -0
  191. package/src/lib/session-summary.ts +68 -0
  192. package/src/lib/session-title.ts +63 -0
  193. package/src/lib/sidebar-groups.ts +78 -0
  194. package/src/lib/skill-installs.ts +360 -0
  195. package/src/lib/skills-catalog.ts +99 -0
  196. package/src/lib/time.ts +22 -0
  197. package/src/lib/tone.ts +80 -0
  198. package/src/lib/turns.ts +320 -0
  199. package/src/lib/utils.ts +6 -0
  200. package/src/logo.svg +31 -0
  201. package/src/pages/projects/ProjectDetailPage.tsx +130 -0
  202. package/src/pages/projects/ProjectsPage.tsx +97 -0
  203. package/src/pages/sessions/SessionPage.tsx +122 -0
  204. package/src/pages/settings/SettingsPage.tsx +299 -0
  205. package/src/pages/settings/__tests__/SettingsPage.test.ts +9 -0
  206. package/src/pages/skills/SkillsPage.tsx +701 -0
  207. package/src/paths.ts +121 -0
  208. package/src/runtime.ts +24 -0
  209. package/src/server/__tests__/app.test.ts +1031 -0
  210. package/src/server/__tests__/auth.test.ts +370 -0
  211. package/src/server/__tests__/config.test.ts +101 -0
  212. package/src/server/__tests__/db.test.ts +485 -0
  213. package/src/server/__tests__/host.test.ts +14 -0
  214. package/src/server/__tests__/tunnel-config.test.ts +38 -0
  215. package/src/server/app.ts +415 -0
  216. package/src/server/db/auth.ts +311 -0
  217. package/src/server/db/messages.ts +50 -0
  218. package/src/server/db/projects.ts +106 -0
  219. package/src/server/db/prompts.ts +78 -0
  220. package/src/server/db/rows.ts +150 -0
  221. package/src/server/db/schema.ts +221 -0
  222. package/src/server/db/sessions.ts +239 -0
  223. package/src/server/db/tunnel.ts +124 -0
  224. package/src/server/host.ts +12 -0
  225. package/src/server/result.ts +15 -0
  226. package/src/server/routes/auth-guard.ts +156 -0
  227. package/src/server/routes/auth.routes.ts +287 -0
  228. package/src/server/routes/helpers.ts +165 -0
  229. package/src/server/routes/meta.routes.ts +24 -0
  230. package/src/server/routes/passkeys.routes.ts +161 -0
  231. package/src/server/routes/projects.routes.ts +157 -0
  232. package/src/server/routes/sessions.routes.ts +197 -0
  233. package/src/server/routes/skills.routes.ts +117 -0
  234. package/src/server/routes/tunnel.routes.ts +132 -0
  235. package/src/server/routes/types.ts +35 -0
  236. package/src/server/routes/uploads.routes.ts +54 -0
  237. package/src/server/routes/workspace.routes.ts +127 -0
  238. package/src/server/services/__tests__/app-info.test.ts +18 -0
  239. package/src/server/services/__tests__/attachments.test.ts +159 -0
  240. package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
  241. package/src/server/services/__tests__/auth.test.ts +341 -0
  242. package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
  243. package/src/server/services/__tests__/git-info.test.ts +74 -0
  244. package/src/server/services/__tests__/opencode.test.ts +320 -0
  245. package/src/server/services/__tests__/project-manager.test.ts +190 -0
  246. package/src/server/services/__tests__/sandbox.test.ts +141 -0
  247. package/src/server/services/__tests__/session-manager.test.ts +3469 -0
  248. package/src/server/services/__tests__/skill-install-jobs.test.ts +226 -0
  249. package/src/server/services/__tests__/skills-registry.test.ts +359 -0
  250. package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
  251. package/src/server/services/__tests__/tunnel.test.ts +367 -0
  252. package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
  253. package/src/server/services/__tests__/workspace.test.ts +249 -0
  254. package/src/server/services/app-info.ts +29 -0
  255. package/src/server/services/attachments.ts +193 -0
  256. package/src/server/services/auth-passkeys.ts +459 -0
  257. package/src/server/services/auth.ts +633 -0
  258. package/src/server/services/frpc-installer.ts +183 -0
  259. package/src/server/services/git-info.ts +87 -0
  260. package/src/server/services/opencode-client.ts +714 -0
  261. package/src/server/services/opencode-server.ts +319 -0
  262. package/src/server/services/opencode-sse.ts +82 -0
  263. package/src/server/services/project-manager.ts +147 -0
  264. package/src/server/services/sandbox.ts +83 -0
  265. package/src/server/services/session-events.ts +256 -0
  266. package/src/server/services/session-manager.ts +1374 -0
  267. package/src/server/services/skill-install-jobs.ts +262 -0
  268. package/src/server/services/skills-registry.ts +468 -0
  269. package/src/server/services/tunnel-api.ts +207 -0
  270. package/src/server/services/tunnel-config.ts +45 -0
  271. package/src/server/services/tunnel-lhr.ts +387 -0
  272. package/src/server/services/tunnel.ts +633 -0
  273. package/src/server/services/turn-stream.ts +339 -0
  274. package/src/server/services/websocket-gateway.ts +374 -0
  275. package/src/server/services/workspace.ts +223 -0
  276. package/src/server/services/ws-transport.ts +121 -0
  277. package/src/types/context.ts +69 -0
  278. package/src/types/index.ts +13 -0
  279. package/src/types/message.ts +58 -0
  280. package/src/types/project.ts +16 -0
  281. package/src/types/prompt.ts +118 -0
  282. package/src/types/session.ts +52 -0
  283. package/src/types/turns.ts +47 -0
  284. package/src/ws-protocol.ts +93 -0
  285. package/styles/globals.css +311 -0
  286. package/tsconfig.json +37 -0
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Dialog konfirmasi instalasi skill dari skills.sh.
3
+ *
4
+ * Menampilkan sumber, tautan ke halaman skill, dan hasil audit keamanan
5
+ * (`GET /api/skills/audit`) SEBELUM user menekan Install — skill berjalan
6
+ * dengan izin penuh agent, jadi keputusan dibuat dengan informasi lengkap.
7
+ * Skill berstatus `fail` tetap boleh dipasang tapi tombolnya destruktif.
8
+ *
9
+ * Konfirmasi hanya MEMULAI job instalasi; dialog lalu ditutup dan progres +
10
+ * output CLI tampil di notifikasi instalasi (`InstallNotifications`).
11
+ */
12
+ import {
13
+ ExternalLinkIcon,
14
+ ShieldAlertIcon,
15
+ ShieldCheckIcon,
16
+ ShieldQuestionIcon,
17
+ } from "lucide-react";
18
+ import { useEffect, useState } from "react";
19
+ import {
20
+ AlertDialog,
21
+ AlertDialogAction,
22
+ AlertDialogCancel,
23
+ AlertDialogContent,
24
+ AlertDialogDescription,
25
+ AlertDialogFooter,
26
+ AlertDialogHeader,
27
+ AlertDialogTitle,
28
+ } from "@/components/ui/alert-dialog";
29
+ import { Skeleton } from "@/components/ui/skeleton";
30
+ import { Spinner } from "@/components/ui/spinner";
31
+ import { ApiError, apiFetch } from "@/lib/api";
32
+ import { AUDIT_BADGE_CLASS, AUDIT_LABEL, formatInstalls } from "@/lib/skills-catalog";
33
+ import { cn } from "@/lib/utils";
34
+ import type { RegistrySkill, SkillAudit } from "@/server/services/skills-registry";
35
+
36
+ type AuditState =
37
+ | { phase: "loading" }
38
+ | { phase: "error"; message: string }
39
+ | { phase: "ready"; audit: SkillAudit };
40
+
41
+ export interface InstallSkillDialogProps {
42
+ /** Skill yang akan dipasang; `null` = dialog tertutup. */
43
+ skill: RegistrySkill | null;
44
+ projectName: string;
45
+ /** Permintaan memulai instalasi sedang dikirim. */
46
+ starting: boolean;
47
+ /** Pesan bila server menolak memulai (mis. instalasi lain sedang berjalan). */
48
+ startError: string | null;
49
+ onOpenChange: (open: boolean) => void;
50
+ onConfirm: (skill: RegistrySkill) => void;
51
+ }
52
+
53
+ export function InstallSkillDialog({
54
+ skill,
55
+ projectName,
56
+ starting,
57
+ startError,
58
+ onOpenChange,
59
+ onConfirm,
60
+ }: InstallSkillDialogProps) {
61
+ const [audit, setAudit] = useState<AuditState>({ phase: "loading" });
62
+
63
+ useEffect(() => {
64
+ if (!skill) return;
65
+ let cancelled = false;
66
+ setAudit({ phase: "loading" });
67
+ const params = new URLSearchParams({ source: skill.source, skill: skill.skillId });
68
+ apiFetch(`/api/skills/audit?${params}`)
69
+ .then((res) => res.json() as Promise<{ audit: SkillAudit }>)
70
+ .then(({ audit }) => {
71
+ if (!cancelled) setAudit({ phase: "ready", audit });
72
+ })
73
+ .catch((e: unknown) => {
74
+ if (!cancelled) {
75
+ setAudit({
76
+ phase: "error",
77
+ message: e instanceof ApiError ? e.message : "Failed to load the security audit.",
78
+ });
79
+ }
80
+ });
81
+ return () => {
82
+ cancelled = true;
83
+ };
84
+ }, [skill]);
85
+
86
+ const flagged = audit.phase === "ready" && audit.audit.overall === "fail";
87
+
88
+ return (
89
+ <AlertDialog
90
+ open={skill !== null}
91
+ onOpenChange={(next) => {
92
+ if (!starting) onOpenChange(next);
93
+ }}
94
+ >
95
+ <AlertDialogContent className="sm:max-w-md">
96
+ <AlertDialogHeader>
97
+ <AlertDialogTitle className="break-all">Install {skill?.name}?</AlertDialogTitle>
98
+ <AlertDialogDescription>
99
+ The skill will be added to <span className="font-medium">{projectName}</span> under{" "}
100
+ <code className="font-mono text-xs">.agents/skills/</code>. Skills run with the agent's
101
+ full permissions, so review them before use.
102
+ </AlertDialogDescription>
103
+ </AlertDialogHeader>
104
+
105
+ {skill && (
106
+ <div className="flex flex-col gap-3 text-sm">
107
+ <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5">
108
+ <dt className="text-muted-foreground">Source</dt>
109
+ <dd className="min-w-0 truncate font-mono text-xs leading-5">{skill.source}</dd>
110
+ <dt className="text-muted-foreground">Installs</dt>
111
+ <dd className="tabular-nums">{formatInstalls(skill.installs)}</dd>
112
+ </dl>
113
+ <a
114
+ href={skill.url}
115
+ target="_blank"
116
+ rel="noopener noreferrer"
117
+ className="inline-flex w-fit items-center gap-1 text-xs font-medium underline underline-offset-4"
118
+ >
119
+ View on skills.sh
120
+ <ExternalLinkIcon className="size-3" aria-hidden />
121
+ <span className="sr-only">(opens in a new tab)</span>
122
+ </a>
123
+
124
+ <section
125
+ aria-label="Security audit"
126
+ className="flex flex-col gap-2 rounded-xl border p-3"
127
+ >
128
+ <AuditSummary state={audit} />
129
+ </section>
130
+
131
+ <p className="text-xs text-muted-foreground">
132
+ Installation runs in the background. You can follow the output in the notification.
133
+ </p>
134
+ {startError && (
135
+ <p role="alert" className="text-xs text-destructive">
136
+ {startError}
137
+ </p>
138
+ )}
139
+ </div>
140
+ )}
141
+
142
+ <AlertDialogFooter>
143
+ <AlertDialogCancel disabled={starting}>Cancel</AlertDialogCancel>
144
+ <AlertDialogAction
145
+ variant={flagged ? "destructive" : "default"}
146
+ disabled={starting || !skill}
147
+ onClick={(e) => {
148
+ // Dialog tetap terbuka selama instalasi berjalan.
149
+ e.preventDefault();
150
+ if (skill) onConfirm(skill);
151
+ }}
152
+ >
153
+ {starting ? (
154
+ <>
155
+ <Spinner data-icon="inline-start" />
156
+ Starting…
157
+ </>
158
+ ) : flagged ? (
159
+ "Install anyway"
160
+ ) : (
161
+ "Install"
162
+ )}
163
+ </AlertDialogAction>
164
+ </AlertDialogFooter>
165
+ </AlertDialogContent>
166
+ </AlertDialog>
167
+ );
168
+ }
169
+
170
+ function AuditSummary({ state }: { state: AuditState }) {
171
+ if (state.phase === "loading") {
172
+ return (
173
+ <div role="status" className="flex flex-col gap-2" aria-busy="true">
174
+ <span className="sr-only">Loading security audit…</span>
175
+ <Skeleton className="h-4 w-1/2" />
176
+ <Skeleton className="h-3 w-3/4" />
177
+ </div>
178
+ );
179
+ }
180
+ if (state.phase === "error") {
181
+ return <p className="text-xs text-muted-foreground">{state.message}</p>;
182
+ }
183
+
184
+ const { audits, overall } = state.audit;
185
+ if (audits === null || overall === null) {
186
+ return (
187
+ <p className="flex items-center gap-2 text-xs text-muted-foreground">
188
+ <ShieldQuestionIcon className="size-4 shrink-0" aria-hidden />
189
+ No security audit is available for this skill yet.
190
+ </p>
191
+ );
192
+ }
193
+
194
+ const Icon = overall === "pass" ? ShieldCheckIcon : ShieldAlertIcon;
195
+ return (
196
+ <>
197
+ <p className="flex items-center gap-2 text-xs font-medium">
198
+ <Icon className="size-4 shrink-0" aria-hidden />
199
+ Security audit
200
+ <span
201
+ className={cn(
202
+ "rounded px-1.5 text-[11px] leading-5 font-medium",
203
+ AUDIT_BADGE_CLASS[overall],
204
+ )}
205
+ >
206
+ {AUDIT_LABEL[overall]}
207
+ </span>
208
+ </p>
209
+ <ul className="flex flex-col gap-1.5">
210
+ {audits.map((a) => (
211
+ <li key={a.provider} className="flex items-start justify-between gap-3 text-xs">
212
+ <span className="min-w-0">
213
+ <span className="font-medium">{a.provider}</span>
214
+ {a.summary && (
215
+ <span className="line-clamp-2 text-muted-foreground" title={a.summary}>
216
+ {a.summary}
217
+ </span>
218
+ )}
219
+ </span>
220
+ <span
221
+ className={cn(
222
+ "shrink-0 rounded px-1.5 text-[11px] leading-5 font-medium",
223
+ AUDIT_BADGE_CLASS[a.status],
224
+ )}
225
+ >
226
+ {AUDIT_LABEL[a.status]}
227
+ </span>
228
+ </li>
229
+ ))}
230
+ </ul>
231
+ </>
232
+ );
233
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Kartu skill untuk grid 2 kolom di halaman Skills.
3
+ *
4
+ * - `RegistrySkillCard`: hasil skills.sh — nama (tautan), owner/repo, jumlah
5
+ * install, tombol Install / "Installing…" (klik = lihat progres) /
6
+ * status "Installed".
7
+ * - `InstalledSkillCard`: skill terpasang — nama, label asal, deskripsi.
8
+ * - `SkillCardSkeleton`: placeholder saat memuat.
9
+ * Semua kartu setinggi baris grid (`h-full`) agar grid rapi.
10
+ */
11
+ import { CheckIcon, DownloadIcon, ExternalLinkIcon, PlusIcon, SparklesIcon } from "lucide-react";
12
+ import type { ReactNode } from "react";
13
+ import { Button } from "@/components/ui/button";
14
+ import { Skeleton } from "@/components/ui/skeleton";
15
+ import { Spinner } from "@/components/ui/spinner";
16
+ import { SKILL_SOURCE_LABEL } from "@/lib/project-extensions";
17
+ import { formatInstalls, githubOwner } from "@/lib/skills-catalog";
18
+ import { cn } from "@/lib/utils";
19
+ import type { SkillInfo } from "@/server/services/opencode-client";
20
+ import type { RegistrySkill } from "@/server/services/skills-registry";
21
+
22
+ /** Grid kartu: 1 kolom di HP, 2 kolom mulai `md`. */
23
+ export function SkillGrid({ label, children }: { label: string; children: ReactNode }) {
24
+ return (
25
+ <ul aria-label={label} className="grid grid-cols-1 gap-3 md:grid-cols-2">
26
+ {children}
27
+ </ul>
28
+ );
29
+ }
30
+
31
+ function CardShell({ className, children }: { className?: string; children: ReactNode }) {
32
+ return (
33
+ <li
34
+ className={cn(
35
+ "flex h-full min-w-0 flex-col gap-3 rounded-xl border bg-card p-4 shadow-xs transition-colors",
36
+ className,
37
+ )}
38
+ >
39
+ {children}
40
+ </li>
41
+ );
42
+ }
43
+
44
+ /** Avatar owner GitHub; jatuh ke ikon bila bukan sumber GitHub / gagal dimuat. */
45
+ function SourceAvatar({ source }: { source: string }) {
46
+ const owner = githubOwner(source);
47
+ return (
48
+ <span className="relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-muted-foreground">
49
+ <SparklesIcon className="size-4" aria-hidden />
50
+ {owner && (
51
+ <img
52
+ src={`https://github.com/${encodeURIComponent(owner)}.png?size=80`}
53
+ alt=""
54
+ loading="lazy"
55
+ referrerPolicy="no-referrer"
56
+ className="absolute inset-0 size-full object-cover"
57
+ onError={(e) => {
58
+ e.currentTarget.style.display = "none";
59
+ }}
60
+ />
61
+ )}
62
+ </span>
63
+ );
64
+ }
65
+
66
+ export function RegistrySkillCard({
67
+ skill,
68
+ installed,
69
+ installing,
70
+ onInstall,
71
+ onShowProgress,
72
+ }: {
73
+ skill: RegistrySkill;
74
+ installed: boolean;
75
+ /** Ada job instalasi berjalan untuk skill ini di Project terpilih. */
76
+ installing: boolean;
77
+ onInstall: (skill: RegistrySkill) => void;
78
+ /** Tampilkan lagi notifikasi progres instalasinya. */
79
+ onShowProgress: () => void;
80
+ }) {
81
+ return (
82
+ <CardShell className="hover:border-foreground/20">
83
+ <div className="flex min-w-0 items-start gap-3">
84
+ <SourceAvatar source={skill.source} />
85
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
86
+ <a
87
+ href={skill.url}
88
+ target="_blank"
89
+ rel="noopener noreferrer"
90
+ title={skill.name}
91
+ className="group/link flex min-w-0 items-center gap-1 text-sm font-semibold leading-5 hover:underline"
92
+ >
93
+ <span className="truncate">{skill.name}</span>
94
+ <ExternalLinkIcon
95
+ className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/link:opacity-100 group-focus-visible/link:opacity-100"
96
+ aria-hidden
97
+ />
98
+ <span className="sr-only"> on skills.sh (opens in a new tab)</span>
99
+ </a>
100
+ <span className="truncate font-mono text-xs text-muted-foreground" title={skill.source}>
101
+ {skill.source}
102
+ </span>
103
+ </div>
104
+ </div>
105
+
106
+ <div className="mt-auto flex items-center justify-between gap-2">
107
+ <span className="flex items-center gap-1 text-xs tabular-nums text-muted-foreground">
108
+ <DownloadIcon className="size-3.5" aria-hidden />
109
+ <span className="sr-only">Installs:</span>
110
+ {formatInstalls(skill.installs)}
111
+ <span aria-hidden> installs</span>
112
+ </span>
113
+ {installing ? (
114
+ <Button
115
+ type="button"
116
+ variant="secondary"
117
+ size="sm"
118
+ onClick={onShowProgress}
119
+ aria-label={`Installing ${skill.name}. Show progress`}
120
+ >
121
+ <Spinner data-icon="inline-start" />
122
+ Installing…
123
+ </Button>
124
+ ) : installed ? (
125
+ <span className="flex h-8 items-center gap-1 rounded-md bg-emerald-500/10 px-2.5 text-xs font-medium text-emerald-700 dark:text-emerald-400">
126
+ <CheckIcon className="size-3.5" aria-hidden />
127
+ Installed
128
+ </span>
129
+ ) : (
130
+ <Button
131
+ type="button"
132
+ variant="outline"
133
+ size="sm"
134
+ onClick={() => onInstall(skill)}
135
+ aria-label={`Install ${skill.name}`}
136
+ >
137
+ <PlusIcon data-icon="inline-start" />
138
+ Install
139
+ </Button>
140
+ )}
141
+ </div>
142
+ </CardShell>
143
+ );
144
+ }
145
+
146
+ export function InstalledSkillCard({ skill }: { skill: SkillInfo }) {
147
+ const isProject = skill.source === "project";
148
+ return (
149
+ <CardShell>
150
+ <div className="flex min-w-0 items-start gap-3">
151
+ <span
152
+ className={cn(
153
+ "flex size-10 shrink-0 items-center justify-center rounded-lg",
154
+ isProject ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
155
+ )}
156
+ >
157
+ <SparklesIcon className="size-4" aria-hidden />
158
+ </span>
159
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
160
+ <span className="flex min-w-0 items-center gap-1.5">
161
+ <span className="truncate text-sm font-semibold leading-5" title={skill.name}>
162
+ {skill.name}
163
+ </span>
164
+ <span
165
+ className={cn(
166
+ "shrink-0 rounded px-1.5 text-[10px] leading-4 font-medium",
167
+ isProject ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
168
+ )}
169
+ >
170
+ {SKILL_SOURCE_LABEL[skill.source]}
171
+ </span>
172
+ </span>
173
+ <p
174
+ className={cn(
175
+ "line-clamp-3 text-xs leading-relaxed",
176
+ skill.description ? "text-muted-foreground" : "text-muted-foreground/60 italic",
177
+ )}
178
+ title={skill.description ?? undefined}
179
+ >
180
+ {skill.description ?? "No description"}
181
+ </p>
182
+ </div>
183
+ </div>
184
+ </CardShell>
185
+ );
186
+ }
187
+
188
+ export function SkillCardSkeleton({ count = 4 }: { count?: number }) {
189
+ return (
190
+ <div role="status" aria-busy="true" className="grid grid-cols-1 gap-3 md:grid-cols-2">
191
+ <span className="sr-only">Loading skills…</span>
192
+ {Array.from({ length: count }, (_, i) => (
193
+ // biome-ignore lint/suspicious/noArrayIndexKey: placeholder statis
194
+ <div key={i} className="flex flex-col gap-3 rounded-xl border bg-card p-4" aria-hidden>
195
+ <div className="flex items-start gap-3">
196
+ <Skeleton className="size-10 shrink-0 rounded-lg" />
197
+ <div className="flex flex-1 flex-col gap-1.5">
198
+ <Skeleton className="h-4" style={{ width: `${75 - (i % 3) * 15}%` }} />
199
+ <Skeleton className="h-3 w-1/2" />
200
+ </div>
201
+ </div>
202
+ <div className="flex items-center justify-between">
203
+ <Skeleton className="h-3 w-20" />
204
+ <Skeleton className="h-8 w-20 rounded-md" />
205
+ </div>
206
+ </div>
207
+ ))}
208
+ </div>
209
+ );
210
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Pemilih Project di halaman Skills — pil ringkas "Install to <project>"
3
+ * yang membuka popover berisi daftar Project dengan kotak cari.
4
+ *
5
+ * Kotak cari hanya muncul bila Project cukup banyak. Navigasi keyboard:
6
+ * ↑/↓ memindah sorotan, Enter memilih, Esc menutup (bawaan Popover).
7
+ */
8
+ import { CheckIcon, ChevronsUpDownIcon, FolderIcon, SearchIcon } from "lucide-react";
9
+ import { useEffect, useMemo, useRef, useState } from "react";
10
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
11
+ import { cn } from "@/lib/utils";
12
+ import type { Project } from "@/types";
13
+
14
+ /** Kotak cari tampil mulai jumlah Project ini. */
15
+ const SEARCH_THRESHOLD = 6;
16
+
17
+ export interface SkillsProjectPickerProps {
18
+ projects: Project[];
19
+ selected: Project | null;
20
+ onSelect: (projectId: string) => void;
21
+ }
22
+
23
+ export function SkillsProjectPicker({ projects, selected, onSelect }: SkillsProjectPickerProps) {
24
+ const [open, setOpen] = useState(false);
25
+ const [query, setQuery] = useState("");
26
+ const [highlight, setHighlight] = useState(0);
27
+ const listRef = useRef<HTMLDivElement>(null);
28
+ const searchRef = useRef<HTMLInputElement>(null);
29
+ const showSearch = projects.length >= SEARCH_THRESHOLD;
30
+
31
+ const filtered = useMemo(() => {
32
+ const q = query.trim().toLowerCase();
33
+ if (q === "") return projects;
34
+ return projects.filter(
35
+ (p) => p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q),
36
+ );
37
+ }, [projects, query]);
38
+
39
+ // Buka: reset pencarian, sorot Project terpilih.
40
+ useEffect(() => {
41
+ if (!open) return;
42
+ setQuery("");
43
+ const idx = projects.findIndex((p) => p.id === selected?.id);
44
+ setHighlight(idx >= 0 ? idx : 0);
45
+ }, [open, projects, selected?.id]);
46
+
47
+ // biome-ignore lint/correctness/useExhaustiveDependencies: reset saat query berubah
48
+ useEffect(() => {
49
+ setHighlight(0);
50
+ }, [query]);
51
+
52
+ useEffect(() => {
53
+ listRef.current
54
+ ?.querySelector<HTMLElement>(`[data-index="${highlight}"]`)
55
+ ?.scrollIntoView({ block: "nearest" });
56
+ }, [highlight]);
57
+
58
+ const pick = (p: Project | undefined) => {
59
+ if (!p) return;
60
+ onSelect(p.id);
61
+ setOpen(false);
62
+ };
63
+
64
+ const onKeyDown = (e: React.KeyboardEvent) => {
65
+ if (e.key === "ArrowDown") {
66
+ e.preventDefault();
67
+ setHighlight((h) => Math.min(h + 1, filtered.length - 1));
68
+ } else if (e.key === "ArrowUp") {
69
+ e.preventDefault();
70
+ setHighlight((h) => Math.max(h - 1, 0));
71
+ } else if (e.key === "Enter") {
72
+ e.preventDefault();
73
+ pick(filtered[highlight]);
74
+ }
75
+ };
76
+
77
+ const activeId = filtered[highlight] ? `skills-project-${highlight}` : undefined;
78
+
79
+ return (
80
+ <Popover open={open} onOpenChange={setOpen}>
81
+ <PopoverTrigger asChild>
82
+ {/* Pemilih konteks yang ringkas (pil), terpisah dari kolom cari:
83
+ menjawab "skill dipasang ke mana", bukan bagian dari pencarian. */}
84
+ <button
85
+ type="button"
86
+ aria-haspopup="listbox"
87
+ aria-label={
88
+ selected ? `Install to project ${selected.name}. Change project` : "Select a project"
89
+ }
90
+ className="group inline-flex h-9 max-w-full min-w-0 items-center gap-2 rounded-full border bg-card pr-2.5 pl-3 text-sm shadow-xs transition-colors outline-none hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[state=open]:border-ring"
91
+ >
92
+ <FolderIcon
93
+ className={cn("size-4 shrink-0", selected ? "text-primary" : "text-muted-foreground")}
94
+ aria-hidden
95
+ />
96
+ <span className="shrink-0 text-muted-foreground">Install to</span>
97
+ <span className={cn("truncate font-medium", !selected && "text-muted-foreground")}>
98
+ {selected?.name ?? "Select a project"}
99
+ </span>
100
+ <ChevronsUpDownIcon className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
101
+ </button>
102
+ </PopoverTrigger>
103
+ <PopoverContent
104
+ align="end"
105
+ className="w-80 max-w-[calc(100vw-2rem)] p-0"
106
+ onOpenAutoFocus={(e) => {
107
+ // Fokus ke kotak cari (bila ada), selain itu ke daftar agar ↑/↓ jalan.
108
+ e.preventDefault();
109
+ (searchRef.current ?? listRef.current)?.focus();
110
+ }}
111
+ >
112
+ {showSearch && (
113
+ <div className="flex items-center gap-2 border-b px-3">
114
+ <SearchIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
115
+ <input
116
+ ref={searchRef}
117
+ type="text"
118
+ role="combobox"
119
+ aria-expanded
120
+ aria-controls="skills-project-list"
121
+ aria-activedescendant={activeId}
122
+ aria-label="Search projects"
123
+ value={query}
124
+ onChange={(e) => setQuery(e.target.value)}
125
+ onKeyDown={onKeyDown}
126
+ placeholder="Search projects…"
127
+ className="h-10 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
128
+ />
129
+ </div>
130
+ )}
131
+ <div
132
+ ref={listRef}
133
+ id="skills-project-list"
134
+ role="listbox"
135
+ aria-label="Projects"
136
+ aria-activedescendant={showSearch ? undefined : activeId}
137
+ tabIndex={showSearch ? -1 : 0}
138
+ onKeyDown={showSearch ? undefined : onKeyDown}
139
+ className="max-h-72 overflow-y-auto p-1 outline-none"
140
+ >
141
+ {filtered.length === 0 ? (
142
+ <p className="px-3 py-6 text-center text-sm text-muted-foreground">
143
+ No projects match “{query.trim()}”.
144
+ </p>
145
+ ) : (
146
+ filtered.map((p, i) => {
147
+ const isSelected = p.id === selected?.id;
148
+ return (
149
+ <div
150
+ key={p.id}
151
+ id={`skills-project-${i}`}
152
+ data-index={i}
153
+ role="option"
154
+ aria-selected={isSelected}
155
+ tabIndex={-1}
156
+ onMouseMove={() => setHighlight(i)}
157
+ onClick={() => pick(p)}
158
+ onKeyDown={() => {}}
159
+ className={cn(
160
+ "flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-sm",
161
+ i === highlight && "bg-accent text-accent-foreground",
162
+ )}
163
+ >
164
+ <FolderIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
165
+ <span className="flex min-w-0 flex-1 flex-col">
166
+ <span className="truncate font-medium">{p.name}</span>
167
+ <span className="truncate text-xs text-muted-foreground">{p.path}</span>
168
+ </span>
169
+ {isSelected && <CheckIcon className="size-4 shrink-0" aria-hidden />}
170
+ </div>
171
+ );
172
+ })
173
+ )}
174
+ </div>
175
+ </PopoverContent>
176
+ </Popover>
177
+ );
178
+ }