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,236 @@
1
+ /**
2
+ * Deteksi & penyelesaian referensi `@file` pada input composer (meniru
3
+ * perilaku `@` di opencode TUI).
4
+ *
5
+ * Logika murni yang dapat diuji tanpa DOM:
6
+ * - `activeMention(text, caret)`: token `@query` yang sedang diketik pada
7
+ * posisi kursor (bila ada), beserta rentang [start, end) di string input.
8
+ * - `applyMention(text, range, path)`: ganti token `@query` dengan teks
9
+ * `@path` saat satu saran dipilih.
10
+ *
11
+ * Hook `useFileMention` mengelola state dropdown + fetch `/files?q=`
12
+ * dengan debounce 150 ms; server mencari via index milik opencode.
13
+ */
14
+
15
+ import { useCallback, useEffect, useRef, useState } from "react";
16
+
17
+ export interface ActiveMention {
18
+ /** Teks query setelah `@` (tanpa simbol `@`). */
19
+ query: string;
20
+ /** Posisi awal token (indeks karakter `@`). */
21
+ start: number;
22
+ /** Posisi akhir token (satu melewati karakter terakhir query). */
23
+ end: number;
24
+ }
25
+
26
+ /** Apakah karakter dipisah kata (token `@` berhenti di sini). */
27
+ function isBoundary(ch: string | undefined): boolean {
28
+ if (ch === undefined) return true;
29
+ return /[\s]/.test(ch) || "()[]{}<>,'\"`".includes(ch);
30
+ }
31
+
32
+ /**
33
+ * Token `@query` aktif pada posisi kursor — hanya bila kursor berada di
34
+ * dalam/di akhir token dan token tersebut dimulai dengan `@` yang berdiri
35
+ * sendiri (bukan bagian dari kata lain, mis. email).
36
+ */
37
+ export function activeMention(text: string, caret: number): ActiveMention | null {
38
+ if (caret < 0 || caret > text.length) return null;
39
+ // Cari `@` terakhir sebelum kursor tanpa spasi di antaranya.
40
+ let at = -1;
41
+ for (let i = caret - 1; i >= 0; i--) {
42
+ const ch = text[i];
43
+ if (ch === "@") {
44
+ at = i;
45
+ break;
46
+ }
47
+ if (isBoundary(ch)) return null;
48
+ }
49
+ if (at < 0) return null;
50
+ // `@` harus berdiri sendiri (awal teks atau dipisah karakter non-kata).
51
+ const before = text[at - 1];
52
+ if (before !== undefined && !isBoundary(before)) return null;
53
+ const query = text.slice(at + 1, caret);
54
+ // Query tak boleh memuat pemisah kata (kursor sudah dijaga di atas, tapi
55
+ // token yang tersisa bisa saja panjang — batasi agar fetch masuk akal).
56
+ if (query.length > 200) return null;
57
+ return { query, start: at, end: caret };
58
+ }
59
+
60
+ /** Ganti token `@query` pada rentang `range` dengan `@<path>`. */
61
+ export function applyMention(text: string, range: ActiveMention, path: string): string {
62
+ const replacement = `@${path}`;
63
+ const after = text[range.end];
64
+ // Beri spasi penutup bila karakter setelah token bukan pemisah.
65
+ const suffix = after !== undefined && !isBoundary(after) ? " " : "";
66
+ return text.slice(0, range.start) + replacement + suffix + text.slice(range.end);
67
+ }
68
+
69
+ export interface UseFileMentionResult {
70
+ /** Saran file yang sedang tampil (kosong = dropdown tertutup). */
71
+ suggestions: string[];
72
+ /** Indeks saran yang disorot (navigasi panah). */
73
+ highlighted: number;
74
+ /** Range token `@` aktif saat ini; null bila tidak ada. */
75
+ mention: ActiveMention | null;
76
+ loading: boolean;
77
+ error: string | null;
78
+ /** Dipanggil dari onChange textarea. */
79
+ onInputChange(text: string, caret: number): void;
80
+ /** Pilih saran pada indeks tertentu -> hasil teks baru via `applyMention`. */
81
+ pick(index: number): string | null;
82
+ /** Navigasi keyboard; return true bila event ditangani (blok default). */
83
+ handleKeyDown(e: { key: string; preventDefault(): void }): boolean;
84
+ /**
85
+ * Pasang callback yang dipanggil saat saran dipilih via keyboard
86
+ * (Enter/Tab) — menerima teks input baru hasil substitusi `@path`.
87
+ */
88
+ setOnPick(cb: (next: string) => void): void;
89
+ /** Tutup dropdown (mis. saat blur). */
90
+ close(): void;
91
+ }
92
+
93
+ export interface UseFileMentionOptions {
94
+ /** Endpoint pencarian file, mis. `/api/sessions/:id/files`. */
95
+ endpoint: string;
96
+ /** Jeda debounce fetch (default 150 ms). */
97
+ debounceMs?: number;
98
+ /** Batas saran yang ditampilkan. */
99
+ limit?: number;
100
+ }
101
+
102
+ type FetchFn = (path: string) => Promise<Response>;
103
+
104
+ export function useFileMention(
105
+ opts: UseFileMentionOptions,
106
+ fetchFn: FetchFn = (p) => fetch(p),
107
+ ): UseFileMentionResult {
108
+ const { endpoint, debounceMs = 150, limit = 8 } = opts;
109
+ const [mention, setMention] = useState<ActiveMention | null>(null);
110
+ const [suggestions, setSuggestions] = useState<string[]>([]);
111
+ const [highlighted, setHighlighted] = useState(0);
112
+ const [loading, setLoading] = useState(false);
113
+ const [error, setError] = useState<string | null>(null);
114
+ const lastTextRef = useRef("");
115
+ const lastCaretRef = useRef(0);
116
+
117
+ const close = useCallback(() => {
118
+ setSuggestions([]);
119
+ setMention(null);
120
+ setHighlighted(0);
121
+ setError(null);
122
+ }, []);
123
+
124
+ /** Pasang callback pilihan keyboard (dipanggil sekali dari efek pemanggil). */
125
+ const setOnPick = useCallback((cb: (next: string) => void) => {
126
+ onPickRef.current = cb;
127
+ }, []);
128
+
129
+ // Fetch saran (debounced) setiap mention berubah. fetchFn/endpoint tidak
130
+ // masuk dependency (stabil dari pemanggil; efek cukup pada perubahan token).
131
+ // biome-ignore lint/correctness/useExhaustiveDependencies: dependensi eksternal sengaja dipatok
132
+ useEffect(() => {
133
+ if (!mention) return;
134
+ const controller = new AbortController();
135
+ const timer = setTimeout(() => {
136
+ setLoading(true);
137
+ setError(null);
138
+ fetchFn(`${endpoint}?q=${encodeURIComponent(mention.query)}`)
139
+ .then(async (res) => {
140
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
141
+ const body = (await res.json()) as { files?: unknown };
142
+ const files = Array.isArray(body.files)
143
+ ? body.files.filter((f): f is string => typeof f === "string")
144
+ : [];
145
+ setSuggestions(files.slice(0, limit));
146
+ setHighlighted(0);
147
+ })
148
+ .catch((e) => {
149
+ if ((e as Error).name === "AbortError") return;
150
+ setError("Failed to search files");
151
+ setSuggestions([]);
152
+ })
153
+ .finally(() => {
154
+ if (!controller.signal.aborted) setLoading(false);
155
+ });
156
+ }, debounceMs);
157
+ return () => {
158
+ clearTimeout(timer);
159
+ controller.abort();
160
+ };
161
+ }, [mention?.start, mention?.query]);
162
+
163
+ const onInputChange = useCallback(
164
+ (text: string, caret: number) => {
165
+ lastTextRef.current = text;
166
+ lastCaretRef.current = caret;
167
+ const m = activeMention(text, caret);
168
+ if (m) {
169
+ setMention(m);
170
+ } else {
171
+ close();
172
+ }
173
+ },
174
+ [close],
175
+ );
176
+
177
+ const pick = useCallback(
178
+ (index: number): string | null => {
179
+ if (!mention || suggestions[index] === undefined) return null;
180
+ const next = applyMention(lastTextRef.current, mention, suggestions[index] as string);
181
+ close();
182
+ return next;
183
+ },
184
+ [mention, suggestions, close],
185
+ );
186
+
187
+ /**
188
+ * Ref ke callback pemanggil: hasil `pick` (teks baru) disetel ke state
189
+ * input lewat sini — hook tidak memegang state textarea.
190
+ */
191
+ const onPickRef = useRef<((next: string) => void) | null>(null);
192
+
193
+ const handleKeyDown = useCallback(
194
+ (e: { key: string; preventDefault(): void }): boolean => {
195
+ if (!mention || suggestions.length === 0) return false;
196
+ if (e.key === "ArrowDown") {
197
+ e.preventDefault();
198
+ setHighlighted((h) => (h + 1) % suggestions.length);
199
+ return true;
200
+ }
201
+ if (e.key === "ArrowUp") {
202
+ e.preventDefault();
203
+ setHighlighted((h) => (h - 1 + suggestions.length) % suggestions.length);
204
+ return true;
205
+ }
206
+ if (e.key === "Enter" || e.key === "Tab") {
207
+ e.preventDefault();
208
+ const next = pick(highlighted);
209
+ // Teruskan teks baru ke pemanggil — tanpa ini hasil pilihan keyboard
210
+ // terbuang (bug: Enter tidak menyisipkan file).
211
+ if (next !== null) onPickRef.current?.(next);
212
+ return next !== null;
213
+ }
214
+ if (e.key === "Escape") {
215
+ e.preventDefault();
216
+ close();
217
+ return true;
218
+ }
219
+ return false;
220
+ },
221
+ [mention, suggestions, highlighted, pick, close],
222
+ );
223
+
224
+ return {
225
+ suggestions,
226
+ highlighted,
227
+ mention,
228
+ loading,
229
+ error,
230
+ onInputChange,
231
+ pick,
232
+ handleKeyDown,
233
+ setOnPick,
234
+ close,
235
+ };
236
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Engine homepage (`ProjectsPage`): daftar Project + ringkasan per Project.
3
+ *
4
+ * `GET /api/projects` + `GET /api/sessions` dimuat bersamaan, lalu
5
+ * ringkasan & pencarian dihitung `lib/project-overview.ts` (murni, teruji).
6
+ * Komponen halaman tinggal merender hasilnya.
7
+ */
8
+ import { useCallback, useEffect, useMemo, useState } from "react";
9
+ import { ApiError, apiFetch } from "@/lib/api";
10
+ import {
11
+ buildProjectOverviews,
12
+ filterProjectOverviews,
13
+ type ProjectOverview,
14
+ totalRunningSessions,
15
+ } from "@/lib/project-overview";
16
+ import type { Project, Session } from "@/types";
17
+
18
+ /** Ambang jumlah Project sebelum kolom pencarian ditampilkan. */
19
+ const SEARCH_THRESHOLD = 5;
20
+
21
+ export interface UseProjectsResult {
22
+ overviews: ProjectOverview[];
23
+ loading: boolean;
24
+ loadError: string | null;
25
+ refresh: () => Promise<void>;
26
+ /** Hasil pencarian (nama/path) dari `overviews`. */
27
+ visible: ProjectOverview[];
28
+ running: number;
29
+ showSearch: boolean;
30
+ /** Dialog pembuatan Project baru. */
31
+ dialogOpen: boolean;
32
+ setDialogOpen: (open: boolean) => void;
33
+ query: string;
34
+ setQuery: (query: string) => void;
35
+ }
36
+
37
+ export function useProjects(): UseProjectsResult {
38
+ const [overviews, setOverviews] = useState<ProjectOverview[]>([]);
39
+ const [loading, setLoading] = useState(true);
40
+ const [loadError, setLoadError] = useState<string | null>(null);
41
+ const [dialogOpen, setDialogOpen] = useState(false);
42
+ const [query, setQuery] = useState("");
43
+
44
+ const refresh = useCallback(async () => {
45
+ setLoading(true);
46
+ setLoadError(null);
47
+ try {
48
+ // Session dimuat bersamaan supaya tiap baris bisa menampilkan status &
49
+ // target "Lanjutkan" tanpa request tambahan per Project.
50
+ const [projectsRes, sessionsRes] = await Promise.all([
51
+ apiFetch("/api/projects"),
52
+ apiFetch("/api/sessions"),
53
+ ]);
54
+ const [{ projects }, { sessions }] = (await Promise.all([
55
+ projectsRes.json(),
56
+ sessionsRes.json(),
57
+ ])) as [{ projects: Project[] }, { sessions: Session[] }];
58
+ setOverviews(buildProjectOverviews(projects, sessions));
59
+ } catch (e) {
60
+ setOverviews([]);
61
+ setLoadError(e instanceof ApiError ? e.message : "Failed to load projects");
62
+ } finally {
63
+ setLoading(false);
64
+ }
65
+ }, []);
66
+
67
+ useEffect(() => {
68
+ void refresh();
69
+ }, [refresh]);
70
+
71
+ const visible = useMemo(() => filterProjectOverviews(overviews, query), [overviews, query]);
72
+ const running = totalRunningSessions(overviews);
73
+ const showSearch = overviews.length >= SEARCH_THRESHOLD;
74
+
75
+ return {
76
+ overviews,
77
+ loading,
78
+ loadError,
79
+ refresh,
80
+ visible,
81
+ running,
82
+ showSearch,
83
+ dialogOpen,
84
+ setDialogOpen,
85
+ query,
86
+ setQuery,
87
+ };
88
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `useResyncOnReconnect` — muat ulang data setelah koneksi WebSocket
3
+ * TERSAMBUNG ULANG (bukan saat koneksi pertama).
4
+ *
5
+ * Broadcast (status, hapus, data berubah) yang terjadi selama koneksi putus
6
+ * tidak dikirim ulang oleh server, jadi daftar di layar bisa basi. Begitu
7
+ * status kembali `connected` setelah sebelumnya pernah terhubung, `resync`
8
+ * dipanggil sekali untuk menutup celah itu.
9
+ */
10
+ import { useEffect, useRef } from "react";
11
+ import type { WsConnectionStatus } from "@/hooks/useWebSocket";
12
+
13
+ export function useResyncOnReconnect(status: WsConnectionStatus, resync: () => unknown): void {
14
+ const wasConnected = useRef(false);
15
+ const lost = useRef(false);
16
+ const resyncRef = useRef(resync);
17
+ resyncRef.current = resync;
18
+
19
+ useEffect(() => {
20
+ if (status === "connected") {
21
+ if (wasConnected.current && lost.current) void resyncRef.current();
22
+ wasConnected.current = true;
23
+ lost.current = false;
24
+ } else if (wasConnected.current) {
25
+ lost.current = true;
26
+ }
27
+ }, [status]);
28
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Router URL tipis untuk shell SPA KCG Code (tanpa dependency).
3
+ *
4
+ * - Server (`src/server/app.ts`) menyajikan shell SPA hanya untuk rute halaman
5
+ * yang terdaftar di `src/index.ts` (`spaPaths`), sehingga tiap halaman punya
6
+ * URL sendiri; `components/layout/AppShell.tsx` memetakan
7
+ * `location.pathname` -> view via `parseRoute()` di `lib/routes.ts`.
8
+ * - `navigate()` memakai History API (`pushState`) + registry listener;
9
+ * `useRouter()` berlangganan event `popstate` (tombol back/forward browser)
10
+ * dan registry tersebut (navigasi dari dalam aplikasi).
11
+ * - `navigate(path, { transition })` membungkus perubahan dalam View
12
+ * Transitions API (bila didukung & pengguna tidak memilih reduced motion):
13
+ * DOM baru dirender sinkron (`flushSync`) di dalam callback sehingga browser
14
+ * bisa me-morph elemen bernama `view-transition-name` yang sama antar
15
+ * halaman. Tanpa dukungan -> navigasi biasa, tanpa animasi.
16
+ */
17
+
18
+ import { useCallback, useEffect, useState } from "react";
19
+ import { flushSync } from "react-dom";
20
+
21
+ const ROUTE_CHANGE_EVENT = "kcg-code-route-change";
22
+
23
+ /** Listener perubahan route hasil `navigate()` (popstate ditangani terpisah). */
24
+ const listeners = new Set<() => void>();
25
+
26
+ export interface NavigateOptions {
27
+ /**
28
+ * Nama jenis transisi (mis. "to-session"). Dipasang sebagai atribut
29
+ * `data-vt` di `<html>` selama animasi berjalan agar CSS bisa memilih
30
+ * animasi yang sesuai.
31
+ */
32
+ transition?: string;
33
+ }
34
+
35
+ function commit(path: string): void {
36
+ window.history.pushState({}, "", path);
37
+ window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT));
38
+ for (const listener of listeners) listener();
39
+ }
40
+
41
+ function canAnimate(): boolean {
42
+ return (
43
+ typeof document.startViewTransition === "function" &&
44
+ !window.matchMedia("(prefers-reduced-motion: reduce)").matches
45
+ );
46
+ }
47
+
48
+ /**
49
+ * Navigasi SPA: ubah URL tanpa reload halaman, lalu beri tahu subscriber
50
+ * `useRouter()`. Tidak melakukan apa-apa bila History API tidak tersedia.
51
+ */
52
+ export function navigate(path: string, options: NavigateOptions = {}): void {
53
+ if (typeof window === "undefined" || typeof window.history === "undefined") return;
54
+ const { transition } = options;
55
+ if (!transition || !canAnimate()) {
56
+ commit(path);
57
+ return;
58
+ }
59
+ const root = document.documentElement;
60
+ root.dataset.vt = transition;
61
+ const vt = document.startViewTransition(() => {
62
+ // Render halaman tujuan secara sinkron agar snapshot "sesudah" lengkap.
63
+ flushSync(() => commit(path));
64
+ });
65
+ void vt.finished.finally(() => {
66
+ if (root.dataset.vt === transition) delete root.dataset.vt;
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Hook: pathname saat ini, diperbarui saat navigasi SPA (`navigate()`) maupun
72
+ * tombol back/forward browser (`popstate`).
73
+ */
74
+ export function useRouter(): { pathname: string; navigate: typeof navigate } {
75
+ const [pathname, setPathname] = useState(() => window.location.pathname);
76
+
77
+ useEffect(() => {
78
+ const sync = () => setPathname(window.location.pathname);
79
+ window.addEventListener("popstate", sync);
80
+ window.addEventListener(ROUTE_CHANGE_EVENT, sync);
81
+ listeners.add(sync);
82
+ return () => {
83
+ window.removeEventListener("popstate", sync);
84
+ window.removeEventListener(ROUTE_CHANGE_EVENT, sync);
85
+ listeners.delete(sync);
86
+ };
87
+ }, []);
88
+
89
+ const go = useCallback((path: string, options?: NavigateOptions) => navigate(path, options), []);
90
+
91
+ return { pathname, navigate: go };
92
+ }