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,362 @@
1
+ /**
2
+ * Store global job instalasi skill (FE) — sumber data notifikasi instalasi.
3
+ *
4
+ * - `startInstall()` memanggil `POST /api/projects/:id/skills/install` (202)
5
+ * lalu mem-poll `GET /api/skills/installs/:id?from=<n>` sampai selesai;
6
+ * baris log baru ditambahkan bertahap (output "live").
7
+ * - Store hidup di modul (bukan komponen) sehingga instalasi & notifikasinya
8
+ * tetap berjalan saat user pindah halaman atau menutup dialog.
9
+ * - `hydrateInstalls()` memulihkan notifikasi setelah reload: job berjalan
10
+ * DAN job selesai yang belum ditutup (server menyimpannya ~10 menit), lengkap
11
+ * dengan log. Status tampilan (disembunyikan / panel output terbuka) disimpan
12
+ * di localStorage sehingga kembali persis seperti sebelum refresh.
13
+ * - Selesai sukses -> `onInstallFinished` (mis. halaman Skills memuat ulang
14
+ * daftar terpasang).
15
+ * - Sukses tapi belum aktif (`refreshed: false`, ada chat berjalan) -> polling
16
+ * pelan berlanjut sampai server mengaktifkannya otomatis setelah chat selesai.
17
+ *
18
+ * `useSyncExternalStore` membaca snapshot; setiap perubahan membuat objek
19
+ * baru (immutable) agar React me-render ulang.
20
+ */
21
+ import { useSyncExternalStore } from "react";
22
+ import { ApiError, apiFetch } from "@/lib/api";
23
+ import type { InstallJob, InstallJobLog } from "@/server/services/skill-install-jobs";
24
+ import type { AgentType } from "@/types";
25
+
26
+ /** Interval polling saat job berjalan. */
27
+ const POLL_MS = 700;
28
+ /**
29
+ * Interval polling job sukses yang skill-nya belum aktif (menunggu chat di
30
+ * Project selesai lalu agent memuat ulang skill otomatis).
31
+ */
32
+ const PENDING_ACTIVATION_POLL_MS = 3000;
33
+ /** Berhenti menunggu aktivasi setelah selama ini (chat sangat panjang). */
34
+ const PENDING_ACTIVATION_MAX_MS = 30 * 60_000;
35
+ /** Batas baris log yang disimpan di client (server juga membatasi). */
36
+ const MAX_CLIENT_LINES = 500;
37
+
38
+ export interface InstallEntry {
39
+ job: InstallJob;
40
+ lines: string[];
41
+ /** Indeks absolut baris berikutnya yang diminta ke server. */
42
+ next: number;
43
+ /** Sebagian baris awal sudah terbuang di server. */
44
+ truncated: boolean;
45
+ /** User menyembunyikan notifikasi ini. */
46
+ dismissed: boolean;
47
+ /** Panel log dibuka. */
48
+ expanded: boolean;
49
+ /** Pesan error jaringan saat polling (job mungkin tetap berjalan). */
50
+ pollError: string | null;
51
+ /** Permintaan pembatalan sudah dikirim (menunggu proses berhenti). */
52
+ cancelling: boolean;
53
+ }
54
+
55
+ type Listener = () => void;
56
+
57
+ let entries: InstallEntry[] = [];
58
+ const listeners = new Set<Listener>();
59
+ const finishListeners = new Set<(job: InstallJob) => void>();
60
+ const polling = new Set<string>();
61
+
62
+ function emit(): void {
63
+ for (const l of listeners) l();
64
+ }
65
+
66
+ function update(jobId: string, fn: (e: InstallEntry) => InstallEntry): void {
67
+ let changed = false;
68
+ entries = entries.map((e) => {
69
+ if (e.job.id !== jobId) return e;
70
+ changed = true;
71
+ return fn(e);
72
+ });
73
+ if (changed) emit();
74
+ }
75
+
76
+ function upsert(entry: InstallEntry): void {
77
+ const exists = entries.some((e) => e.job.id === entry.job.id);
78
+ entries = exists
79
+ ? entries.map((e) => (e.job.id === entry.job.id ? entry : e))
80
+ : [entry, ...entries];
81
+ emit();
82
+ }
83
+
84
+ function newEntry(job: InstallJob, expanded: boolean, dismissed = false): InstallEntry {
85
+ return {
86
+ job,
87
+ lines: [],
88
+ next: 0,
89
+ truncated: false,
90
+ dismissed,
91
+ expanded,
92
+ pollError: null,
93
+ cancelling: false,
94
+ };
95
+ }
96
+
97
+ // ------------------------------------------------ status tampilan (persist) ----
98
+
99
+ const UI_STATE_KEY = "kcg-skill-installs-ui";
100
+
101
+ /** Status tampilan per job yang bertahan melewati refresh. */
102
+ export interface InstallUiState {
103
+ /** Job yang notifikasinya disembunyikan / ditutup user. */
104
+ dismissed: string[];
105
+ /** Job yang panel output-nya terbuka. */
106
+ expanded: string[];
107
+ }
108
+
109
+ function readUiState(): InstallUiState {
110
+ try {
111
+ const raw = localStorage.getItem(UI_STATE_KEY);
112
+ if (!raw) return { dismissed: [], expanded: [] };
113
+ const v = JSON.parse(raw) as Partial<InstallUiState>;
114
+ const ids = (x: unknown) => (Array.isArray(x) ? x.filter((i) => typeof i === "string") : []);
115
+ return { dismissed: ids(v.dismissed), expanded: ids(v.expanded) };
116
+ } catch {
117
+ return { dismissed: [], expanded: [] };
118
+ }
119
+ }
120
+
121
+ /** Job selesai yang ditutup tidak ada lagi di `entries`; ingat id-nya di sini. */
122
+ let closedIds = new Set<string>(readUiState().dismissed);
123
+
124
+ function saveUiState(): void {
125
+ const state: InstallUiState = {
126
+ dismissed: [
127
+ ...new Set([...closedIds, ...entries.filter((e) => e.dismissed).map((e) => e.job.id)]),
128
+ ],
129
+ expanded: entries.filter((e) => e.expanded).map((e) => e.job.id),
130
+ };
131
+ try {
132
+ localStorage.setItem(UI_STATE_KEY, JSON.stringify(state));
133
+ } catch {
134
+ // Storage tidak tersedia (mode privat) — status hanya di memori.
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Entry yang dipulihkan dari daftar job server (murni — diuji). Job berjalan
140
+ * selalu dipulihkan (walau disembunyikan, agar kartu tetap tahu statusnya);
141
+ * job selesai hanya bila belum ditutup. Urutan: terbaru dulu.
142
+ */
143
+ export function hydrationEntries(
144
+ jobs: readonly InstallJob[],
145
+ ui: InstallUiState,
146
+ existingIds: ReadonlySet<string>,
147
+ ): InstallEntry[] {
148
+ const dismissed = new Set(ui.dismissed);
149
+ const expanded = new Set(ui.expanded);
150
+ return jobs
151
+ .filter((job) => !existingIds.has(job.id))
152
+ .filter((job) => job.status === "running" || !dismissed.has(job.id))
153
+ .map((job) => newEntry(job, expanded.has(job.id), dismissed.has(job.id)));
154
+ }
155
+
156
+ /** Gabungkan potongan log baru ke entry (murni — diuji). */
157
+ export function mergeLog(entry: InstallEntry, log: InstallJobLog): InstallEntry {
158
+ // Hanya ambil baris yang belum kita punya (polling bisa tumpang tindih).
159
+ const skip = Math.max(0, entry.next - log.from);
160
+ const fresh = log.lines.slice(skip);
161
+ let lines = fresh.length > 0 ? [...entry.lines, ...fresh] : entry.lines;
162
+ let truncated = entry.truncated || log.truncated;
163
+ if (lines.length > MAX_CLIENT_LINES) {
164
+ lines = lines.slice(lines.length - MAX_CLIENT_LINES);
165
+ truncated = true;
166
+ }
167
+ return {
168
+ ...entry,
169
+ job: log.job,
170
+ lines,
171
+ next: Math.max(entry.next, log.from + log.lines.length),
172
+ truncated,
173
+ pollError: null,
174
+ };
175
+ }
176
+
177
+ async function poll(jobId: string): Promise<void> {
178
+ if (polling.has(jobId)) return;
179
+ polling.add(jobId);
180
+ try {
181
+ for (;;) {
182
+ const cur = entries.find((e) => e.job.id === jobId);
183
+ if (!cur) return;
184
+ try {
185
+ const res = await apiFetch(`/api/skills/installs/${jobId}?from=${cur.next}`);
186
+ const log = (await res.json()) as InstallJobLog;
187
+ const wasRunning = cur.job.status === "running";
188
+ update(jobId, (e) => mergeLog(e, log));
189
+ if (log.job.status !== "running") {
190
+ if (wasRunning) for (const l of finishListeners) l(log.job);
191
+ // Terpasang tapi belum aktif (ada chat berjalan) -> terus pantau
192
+ // pelan sampai server menandai aktif, agar notifikasi ikut berubah.
193
+ const waiting =
194
+ log.job.status === "succeeded" &&
195
+ log.job.refreshed === false &&
196
+ Date.now() - (log.job.finishedAt ?? Date.now()) < PENDING_ACTIVATION_MAX_MS;
197
+ if (!waiting) return;
198
+ await new Promise((r) => setTimeout(r, PENDING_ACTIVATION_POLL_MS));
199
+ continue;
200
+ }
201
+ } catch (e) {
202
+ if (e instanceof ApiError && e.status === 404) {
203
+ // Job sudah sukses (sedang menunggu aktivasi) lalu kedaluwarsa /
204
+ // server restart: skill tetap terpasang — jangan ubah jadi gagal.
205
+ // Restart server juga memuat ulang skill, jadi anggap aktif.
206
+ if (cur.job.status === "succeeded") {
207
+ update(jobId, (en) => ({ ...en, job: { ...en.job, refreshed: true } }));
208
+ return;
209
+ }
210
+ // Server restart saat instalasi berjalan: job hilang bersama prosesnya.
211
+ update(jobId, (en) => ({
212
+ ...en,
213
+ job: { ...en.job, status: "failed", error: "INSTALL_JOB_LOST", finishedAt: Date.now() },
214
+ pollError: null,
215
+ }));
216
+ return;
217
+ }
218
+ update(jobId, (en) => ({ ...en, pollError: "Connection lost — retrying…" }));
219
+ }
220
+ await new Promise((r) => setTimeout(r, POLL_MS));
221
+ }
222
+ } finally {
223
+ polling.delete(jobId);
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Mulai instalasi. Melempar `ApiError` bila server menolak sebelum job dibuat
229
+ * (validasi, Project tak dikenal, instalasi lain sedang berjalan).
230
+ */
231
+ export async function startInstall(
232
+ projectId: string,
233
+ source: string,
234
+ skill: string,
235
+ agentTypes: readonly AgentType[] = ["opencode"],
236
+ ): Promise<InstallJob> {
237
+ const res = await apiFetch(`/api/projects/${projectId}/skills/install`, {
238
+ method: "POST",
239
+ body: JSON.stringify({ source, skill, agents: agentTypes }),
240
+ });
241
+ const { job } = (await res.json()) as { job: InstallJob };
242
+ upsert(newEntry(job, true));
243
+ saveUiState();
244
+ void poll(job.id);
245
+ return job;
246
+ }
247
+
248
+ let hydrated = false;
249
+
250
+ /**
251
+ * Pulihkan notifikasi setelah reload (sekali per muat halaman): job berjalan
252
+ * + job selesai yang belum ditutup, beserta status tampilannya. Log diambil
253
+ * ulang dari awal; job selesai cukup satu kali ambil.
254
+ */
255
+ export async function hydrateInstalls(): Promise<void> {
256
+ if (hydrated) return;
257
+ hydrated = true;
258
+ try {
259
+ const res = await apiFetch("/api/skills/installs");
260
+ const { jobs } = (await res.json()) as { jobs: InstallJob[] };
261
+ const ui = readUiState();
262
+ const restored = hydrationEntries(jobs, ui, new Set(entries.map((e) => e.job.id)));
263
+ // Id yang tidak dikenal server lagi (kedaluwarsa) dibuang dari storage.
264
+ const known = new Set(jobs.map((j) => j.id));
265
+ closedIds = new Set(ui.dismissed.filter((id) => known.has(id)));
266
+ if (restored.length > 0) {
267
+ entries = [...entries, ...restored];
268
+ emit();
269
+ }
270
+ saveUiState();
271
+ for (const e of restored) void poll(e.job.id);
272
+ } catch {
273
+ hydrated = false; // coba lagi di kesempatan berikutnya
274
+ }
275
+ }
276
+
277
+ export function dismissInstall(jobId: string): void {
278
+ update(jobId, (e) => ({ ...e, dismissed: true }));
279
+ // Job selesai yang ditutup tidak perlu disimpan lagi — cukup ingat id-nya
280
+ // agar tidak muncul kembali setelah refresh.
281
+ const e = entries.find((x) => x.job.id === jobId);
282
+ if (e && e.job.status !== "running") {
283
+ closedIds.add(jobId);
284
+ entries = entries.filter((x) => x.job.id !== jobId);
285
+ emit();
286
+ }
287
+ saveUiState();
288
+ }
289
+
290
+ /** Minta server membatalkan job; status akhir datang lewat polling. */
291
+ export async function cancelInstall(jobId: string): Promise<void> {
292
+ update(jobId, (e) => ({ ...e, cancelling: true, expanded: true }));
293
+ saveUiState();
294
+ try {
295
+ await apiFetch(`/api/skills/installs/${jobId}/cancel`, { method: "POST" });
296
+ } catch (e) {
297
+ // 409 = sudah selesai duluan; biarkan polling membawa status akhirnya.
298
+ if (!(e instanceof ApiError && e.status === 409)) {
299
+ update(jobId, (en) => ({
300
+ ...en,
301
+ cancelling: false,
302
+ pollError: "Couldn't cancel — try again.",
303
+ }));
304
+ }
305
+ }
306
+ }
307
+
308
+ export function toggleInstallLog(jobId: string): void {
309
+ update(jobId, (e) => ({ ...e, expanded: !e.expanded }));
310
+ saveUiState();
311
+ }
312
+
313
+ /** Tampilkan lagi notifikasi job (mis. klik "Installing…" pada kartu). */
314
+ export function revealInstall(jobId: string): void {
315
+ closedIds.delete(jobId);
316
+ update(jobId, (e) => ({ ...e, dismissed: false, expanded: true }));
317
+ saveUiState();
318
+ }
319
+
320
+ /** Berlangganan event "job selesai"; mengembalikan fungsi berhenti. */
321
+ export function onInstallFinished(cb: (job: InstallJob) => void): () => void {
322
+ finishListeners.add(cb);
323
+ return () => finishListeners.delete(cb);
324
+ }
325
+
326
+ function subscribe(l: Listener): () => void {
327
+ listeners.add(l);
328
+ return () => listeners.delete(l);
329
+ }
330
+
331
+ function snapshot(): InstallEntry[] {
332
+ return entries;
333
+ }
334
+
335
+ export function useInstalls(): InstallEntry[] {
336
+ return useSyncExternalStore(subscribe, snapshot, snapshot);
337
+ }
338
+
339
+ /** Job berjalan untuk Project + skill tertentu (untuk status tombol kartu). */
340
+ export function findRunningInstall(
341
+ list: readonly InstallEntry[],
342
+ projectId: string,
343
+ source: string,
344
+ skillId: string,
345
+ ): InstallEntry | undefined {
346
+ return list.find(
347
+ (e) =>
348
+ e.job.status === "running" &&
349
+ e.job.projectId === projectId &&
350
+ e.job.source === source &&
351
+ e.job.skillId === skillId,
352
+ );
353
+ }
354
+
355
+ /** Hanya untuk test: kosongkan store. */
356
+ export function __resetInstallsForTest(): void {
357
+ entries = [];
358
+ closedIds = new Set();
359
+ hydrated = false;
360
+ polling.clear();
361
+ emit();
362
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Helper tampilan katalog skill (skills.sh) di halaman Skills — logika murni
3
+ * tanpa DOM, dipisah agar dapat diuji `bun test`.
4
+ */
5
+ import { AGENT_TYPES } from "@/lib/agent-types";
6
+ import type { SkillInfo } from "@/server/services/opencode-client";
7
+ import type { AuditStatus, RegistrySkill } from "@/server/services/skills-registry";
8
+ import type { AgentType, Project } from "@/types";
9
+
10
+ /** Kunci localStorage Project terakhir yang dipilih di halaman Skills. */
11
+ export const LAST_SKILLS_PROJECT_KEY = "kcg-skills-project";
12
+
13
+ /** Saran kata kunci saat kolom cari masih kosong (tab Browse). */
14
+ export const SEARCH_SUGGESTIONS = [
15
+ "react",
16
+ "nextjs",
17
+ "testing",
18
+ "typescript",
19
+ "python",
20
+ "design",
21
+ "docs",
22
+ "git",
23
+ ] as const;
24
+
25
+ /**
26
+ * Project terpilih di halaman Skills: `?project=` (dari sidebar) menang, lalu
27
+ * pilihan terakhir yang diingat, lalu satu-satunya Project bila hanya ada
28
+ * satu. Id yang tidak dikenal (Project terhapus) dilewati.
29
+ */
30
+ export function resolveSkillsProject(
31
+ projects: readonly Project[],
32
+ urlId: string | null,
33
+ storedId: string | null,
34
+ ): Project | null {
35
+ for (const id of [urlId, storedId]) {
36
+ if (id === null) continue;
37
+ const hit = projects.find((p) => p.id === id);
38
+ if (hit) return hit;
39
+ }
40
+ return projects.length === 1 ? (projects[0] ?? null) : null;
41
+ }
42
+
43
+ /** Owner GitHub dari `owner/repo`; `null` untuk sumber well-known (domain). */
44
+ export function githubOwner(source: string): string | null {
45
+ const [owner, repo, ...rest] = source.split("/");
46
+ if (!owner || !repo || rest.length > 0) return null;
47
+ return owner;
48
+ }
49
+
50
+ /** Skill terpasang + agent yang memuatnya (gabungan daftar per agent). */
51
+ export interface InstalledSkill extends SkillInfo {
52
+ /** Urut sesuai `AGENT_TYPES`; minimal satu. */
53
+ agents: AgentType[];
54
+ }
55
+
56
+ /**
57
+ * Gabungkan daftar skill per agent menjadi satu daftar: skill dengan asal
58
+ * (`source`) dan nama (case-insensitive) sama dianggap satu skill yang
59
+ * dimuat beberapa agent. Deskripsi diambil dari daftar pertama yang punya.
60
+ */
61
+ export function mergeInstalledSkills(
62
+ byAgent: Partial<Record<AgentType, readonly SkillInfo[]>>,
63
+ ): InstalledSkill[] {
64
+ const merged = new Map<string, InstalledSkill>();
65
+ for (const agent of AGENT_TYPES) {
66
+ for (const skill of byAgent[agent] ?? []) {
67
+ const key = `${skill.source}:${skill.name.toLowerCase()}`;
68
+ const hit = merged.get(key);
69
+ if (!hit) {
70
+ merged.set(key, { ...skill, agents: [agent] });
71
+ continue;
72
+ }
73
+ if (!hit.agents.includes(agent)) hit.agents.push(agent);
74
+ if (hit.description === null) hit.description = skill.description;
75
+ }
76
+ }
77
+ return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
78
+ }
79
+
80
+ /**
81
+ * Agent yang sudah memuat skill katalog ini dari folder Project (cocokkan
82
+ * nama folder / nama). Skill global sengaja tidak dihitung: instalasi di
83
+ * halaman ini memasang ke folder Project.
84
+ */
85
+ export function installedAgentsFor(
86
+ skill: RegistrySkill,
87
+ installed: readonly InstalledSkill[],
88
+ ): AgentType[] {
89
+ const names = new Set([skill.skillId.toLowerCase(), skill.name.toLowerCase()]);
90
+ const agents = new Set<AgentType>();
91
+ for (const s of installed) {
92
+ if (s.source === "project" && names.has(s.name.toLowerCase())) {
93
+ for (const a of s.agents) agents.add(a);
94
+ }
95
+ }
96
+ return AGENT_TYPES.filter((a) => agents.has(a));
97
+ }
98
+
99
+ /**
100
+ * Skill terpasang yang relevan untuk halaman Skills: tanpa bawaan opencode,
101
+ * Project dulu lalu global, difilter kata kunci (nama / deskripsi).
102
+ */
103
+ export function visibleInstalledSkills<T extends SkillInfo>(
104
+ skills: readonly T[],
105
+ query: string,
106
+ ): T[] {
107
+ const q = query.trim().toLowerCase();
108
+ const match = (s: T) =>
109
+ q === "" || s.name.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q);
110
+ return [
111
+ ...skills.filter((s) => s.source === "project" && match(s)),
112
+ ...skills.filter((s) => s.source === "global" && match(s)),
113
+ ];
114
+ }
115
+
116
+ /** Jumlah install ringkas: 950, 1.2K, 752K, 1.3M. */
117
+ export function formatInstalls(n: number): string {
118
+ if (n < 1000) return String(n);
119
+ const fmt = (v: number, unit: string) => {
120
+ const s = v >= 100 ? Math.round(v).toString() : v.toFixed(1).replace(/\.0$/, "");
121
+ return `${s}${unit}`;
122
+ };
123
+ if (n < 1_000_000) return fmt(n / 1000, "K");
124
+ return fmt(n / 1_000_000, "M");
125
+ }
126
+
127
+ /** Label & gaya ringkasan audit keamanan. */
128
+ export const AUDIT_LABEL: Record<AuditStatus, string> = {
129
+ pass: "Passed",
130
+ warn: "Review",
131
+ fail: "Flagged",
132
+ };
133
+
134
+ export const AUDIT_BADGE_CLASS: Record<AuditStatus, string> = {
135
+ pass: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
136
+ warn: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
137
+ fail: "bg-destructive/10 text-destructive",
138
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Format waktu & durasi untuk label UI (jam footer pesan, durasi thinking).
3
+ * Logika murni agar mudah diuji tanpa DOM.
4
+ */
5
+
6
+ /** Jam lokal `HH:MM:SS` untuk footer pesan. */
7
+ export function formatTime(ts: number): string {
8
+ return new Date(ts).toLocaleTimeString(undefined, {
9
+ hour: "2-digit",
10
+ minute: "2-digit",
11
+ second: "2-digit",
12
+ });
13
+ }
14
+
15
+ /** Format durasi ms ke string ringkas: "1s", "12s", "1m 5s". */
16
+ export function formatDuration(ms: number): string {
17
+ const s = Math.round(ms / 1000);
18
+ if (s < 60) return `${s}s`;
19
+ const m = Math.floor(s / 60);
20
+ const rem = s % 60;
21
+ return rem === 0 ? `${m}m` : `${m}m ${rem}s`;
22
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Nada hover rail pesan (Web Audio) — tangga nada mayor.
3
+ *
4
+ * Penanda paling atas berbunyi paling rendah, makin ke bawah makin tinggi
5
+ * (do re mi fa sol la si do…), berhenti menaik setelah dua oktaf.
6
+ * Frekuensinya dihitung murni (`scaleFrequency`, bisa diuji); pemutarannya
7
+ * memakai satu `AudioContext` bersama.
8
+ *
9
+ * Catatan browser: `AudioContext` baru boleh berbunyi setelah ada interaksi
10
+ * yang dianggap "user activation" (klik / tap / tombol). Hover TIDAK termasuk,
11
+ * jadi nada pertama bisa senyap sampai pengguna mengklik sesuatu di halaman.
12
+ * Karena ini hanya hiasan, kegagalan diabaikan diam-diam.
13
+ */
14
+
15
+ /** Jarak semiton nada mayor: do re mi fa sol la si. */
16
+ const MAJOR_STEPS = [0, 2, 4, 5, 7, 9, 11];
17
+
18
+ /** Nada dasar (C4). */
19
+ export const BASE_FREQUENCY = 261.63;
20
+
21
+ /**
22
+ * Nada tertinggi = dua oktaf di atas dasar (do), yaitu derajat ke-14 tangga
23
+ * nada. Di atas itu nada DITAHAN (tidak berulang ke bawah): rail panjang
24
+ * tetap terdengar menaik, dan nadanya tidak pernah melengking (maks ±1 kHz).
25
+ */
26
+ const MAX_DEGREE = 14;
27
+
28
+ /**
29
+ * Frekuensi (Hz) untuk penanda ke-`index` (0 = paling atas / paling rendah).
30
+ * Naik satu derajat tangga nada mayor per penanda, ditahan di `MAX_DEGREE`.
31
+ */
32
+ export function scaleFrequency(index: number, base = BASE_FREQUENCY): number {
33
+ const degree = Math.min(Math.max(0, Math.floor(index)), MAX_DEGREE);
34
+ const step = MAJOR_STEPS[degree % MAJOR_STEPS.length] ?? 0;
35
+ const octave = Math.floor(degree / MAJOR_STEPS.length);
36
+ return base * 2 ** ((step + octave * 12) / 12);
37
+ }
38
+
39
+ type Ctx = AudioContext & { _kcgUnlocked?: boolean };
40
+ let ctx: Ctx | null = null;
41
+
42
+ function audioContext(): Ctx | null {
43
+ if (typeof window === "undefined") return null;
44
+ const Ctor =
45
+ window.AudioContext ??
46
+ (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
47
+ if (!Ctor) return null;
48
+ ctx ??= new Ctor();
49
+ return ctx;
50
+ }
51
+
52
+ /**
53
+ * Bunyikan satu nada pendek & lembut. Tidak pernah melempar: fitur ini
54
+ * sekadar umpan balik, bukan bagian dari alur utama.
55
+ */
56
+ export function playTone(frequency: number): void {
57
+ try {
58
+ const audio = audioContext();
59
+ if (!audio) return;
60
+ if (audio.state === "suspended") void audio.resume();
61
+ if (audio.state !== "running") return;
62
+
63
+ const now = audio.currentTime;
64
+ const osc = audio.createOscillator();
65
+ const gain = audio.createGain();
66
+ // Triangle: lembut, tanpa harmonik tajam seperti square/saw.
67
+ osc.type = "triangle";
68
+ osc.frequency.value = frequency;
69
+ // Envelope pendek (±0.16 s) dengan attack cepat supaya terasa seperti
70
+ // "tik" bernada, bukan bunyi panjang yang mengganggu saat menyapu rail.
71
+ gain.gain.setValueAtTime(0, now);
72
+ gain.gain.linearRampToValueAtTime(0.06, now + 0.012);
73
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.16);
74
+ osc.connect(gain).connect(audio.destination);
75
+ osc.start(now);
76
+ osc.stop(now + 0.18);
77
+ } catch {
78
+ /* Web Audio tidak tersedia / diblokir — abaikan. */
79
+ }
80
+ }