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,164 @@
1
+ /**
2
+ * Hook tema (Requirement 8.2, 8.6).
3
+ *
4
+ * - Preferensi: `light`, `dark`, atau `system` (ikuti perangkat), disimpan di
5
+ * `localStorage["kcg-theme"]`. Tanpa nilai tersimpan -> `system`.
6
+ * - Tema efektif diterapkan ke `<html>` (class `dark`, Requirement 8.6). Pada
7
+ * `system`, perubahan tema OS diikuti langsung (media query listener).
8
+ * - State dibagi global (satu store modul) sehingga semua pemakai — footer
9
+ * sidebar, header Session — selalu sinkron, termasuk antar tab (event
10
+ * `storage`).
11
+ * - `toggleTheme()` tetap ada (Requirement 8.2): berpindah light <-> dark.
12
+ *
13
+ * Logika inti dipisah ke fungsi murni (`resolveTheme`, `resolveInitialTheme`,
14
+ * `applyTheme`, `nextTheme`, `getStoredTheme`, `getStoredPreference`) agar
15
+ * dapat diuji tanpa DOM (bun test).
16
+ */
17
+ import { useCallback, useSyncExternalStore } from "react";
18
+
19
+ export type Theme = "light" | "dark";
20
+ export type ThemePreference = Theme | "system";
21
+
22
+ export const THEME_STORAGE_KEY = "kcg-theme";
23
+ const DARK_QUERY = "(prefers-color-scheme: dark)";
24
+
25
+ /** Minimal kontrak storage & root agar dapat di-mock pada unit test. */
26
+ export interface ThemeStorageLike {
27
+ getItem(key: string): string | null;
28
+ setItem(key: string, value: string): void;
29
+ }
30
+
31
+ export interface ThemeRootLike {
32
+ classList: {
33
+ add(name: string): void;
34
+ remove(name: string): void;
35
+ toggle(name: string, force?: boolean): void;
36
+ };
37
+ }
38
+
39
+ // ------------------------------------------------------------ murni ----
40
+
41
+ /** Tema selanjutnya saat toggle (Requirement 8.2). */
42
+ export function nextTheme(theme: Theme): Theme {
43
+ return theme === "dark" ? "light" : "dark";
44
+ }
45
+
46
+ /** Baca tema eksplisit tersimpan, atau null bila belum ada / `system` / tak dikenal. */
47
+ export function getStoredTheme(storage: ThemeStorageLike): Theme | null {
48
+ const value = storage.getItem(THEME_STORAGE_KEY);
49
+ return value === "dark" || value === "light" ? value : null;
50
+ }
51
+
52
+ /** Preferensi tersimpan; kosong / tak dikenal -> `system`. */
53
+ export function getStoredPreference(storage: ThemeStorageLike): ThemePreference {
54
+ const value = storage.getItem(THEME_STORAGE_KEY);
55
+ return value === "dark" || value === "light" || value === "system" ? value : "system";
56
+ }
57
+
58
+ /** Tema efektif dari preferensi. */
59
+ export function resolveTheme(pref: ThemePreference, prefersDark: () => boolean): Theme {
60
+ if (pref === "system") return prefersDark() ? "dark" : "light";
61
+ return pref;
62
+ }
63
+
64
+ /**
65
+ * Tema awal: preferensi tersimpan; bila tidak ada, fallback ke preferensi
66
+ * sistem (`prefers-color-scheme: dark`), lalu light.
67
+ */
68
+ export function resolveInitialTheme(stored: string | null, prefersDark: () => boolean): Theme {
69
+ if (stored === "dark" || stored === "light") return stored;
70
+ return prefersDark() ? "dark" : "light";
71
+ }
72
+
73
+ /** Terapkan tema ke elemen root (`classList.toggle("dark", ...)`, Requirement 8.6). */
74
+ export function applyTheme(root: ThemeRootLike, theme: Theme): void {
75
+ root.classList.toggle("dark", theme === "dark");
76
+ }
77
+
78
+ // ---------------------------------------------------- store global ----
79
+
80
+ interface ThemeState {
81
+ preference: ThemePreference;
82
+ theme: Theme;
83
+ }
84
+
85
+ const hasDom = typeof window !== "undefined" && typeof document !== "undefined";
86
+ const media = hasDom ? window.matchMedia?.(DARK_QUERY) : undefined;
87
+ const systemDark = () => Boolean(media?.matches);
88
+
89
+ function readStorage(): ThemePreference {
90
+ try {
91
+ return typeof localStorage !== "undefined" ? getStoredPreference(localStorage) : "system";
92
+ } catch {
93
+ return "system";
94
+ }
95
+ }
96
+
97
+ let state: ThemeState = (() => {
98
+ const preference = readStorage();
99
+ return { preference, theme: resolveTheme(preference, systemDark) };
100
+ })();
101
+ const listeners = new Set<() => void>();
102
+
103
+ function commit(preference: ThemePreference, persist: boolean): void {
104
+ const next: ThemeState = { preference, theme: resolveTheme(preference, systemDark) };
105
+ if (hasDom) applyTheme(document.documentElement, next.theme);
106
+ if (persist) {
107
+ try {
108
+ localStorage.setItem(THEME_STORAGE_KEY, preference);
109
+ } catch {
110
+ // Storage tidak tersedia — preferensi hanya berlaku di sesi ini.
111
+ }
112
+ }
113
+ if (next.preference === state.preference && next.theme === state.theme) return;
114
+ state = next;
115
+ for (const l of listeners) l();
116
+ }
117
+
118
+ if (hasDom) {
119
+ applyTheme(document.documentElement, state.theme);
120
+ // OS berganti tema -> ikuti bila preferensi `system`.
121
+ media?.addEventListener?.("change", () => {
122
+ if (state.preference === "system") commit("system", false);
123
+ });
124
+ // Tab lain mengubah preferensi -> sinkronkan.
125
+ window.addEventListener("storage", (e) => {
126
+ if (e.key === THEME_STORAGE_KEY) commit(readStorage(), false);
127
+ });
128
+ }
129
+
130
+ export function setThemePreference(preference: ThemePreference): void {
131
+ commit(preference, true);
132
+ }
133
+
134
+ function subscribe(l: () => void): () => void {
135
+ listeners.add(l);
136
+ return () => listeners.delete(l);
137
+ }
138
+
139
+ function snapshot(): ThemeState {
140
+ return state;
141
+ }
142
+
143
+ // ------------------------------------------------------------ hook ----
144
+
145
+ export interface UseThemeResult {
146
+ /** Tema efektif yang sedang tampil. */
147
+ theme: Theme;
148
+ /** Pilihan pengguna (termasuk `system`). */
149
+ preference: ThemePreference;
150
+ setPreference(pref: ThemePreference): void;
151
+ /** Light <-> dark (keluar dari `system`). */
152
+ toggleTheme(): void;
153
+ }
154
+
155
+ export function useTheme(): UseThemeResult {
156
+ const current = useSyncExternalStore(subscribe, snapshot, snapshot);
157
+ const toggleTheme = useCallback(() => setThemePreference(nextTheme(state.theme)), []);
158
+ return {
159
+ theme: current.theme,
160
+ preference: current.preference,
161
+ setPreference: setThemePreference,
162
+ toggleTheme,
163
+ };
164
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Hook WebSocket Client (task 22.3).
3
+ *
4
+ * Mengelola koneksi ke `/ws` server KCG Code:
5
+ * - `attach(sessionId)` membuka koneksi (bila perlu) dan mengirim pesan
6
+ * `attach` — setelah reconnect otomatis, `attach` dikirim ulang agar riwayat
7
+ * tersinkronisasi (Requirement 4.1).
8
+ * - Reconnect otomatis dengan jeda konfigurabel saat koneksi putus tak terduga;
9
+ * `disconnect()` menutup koneksi secara manual tanpa reconnect.
10
+ * - Pesan masuk (`history`, `output`, `prompt`, `prompt_resolved`,
11
+ * `session_status`, `error`) diteruskan ke `onMessage` sesuai `ws-protocol.ts`.
12
+ */
13
+ import { useCallback, useEffect, useRef, useState } from "react";
14
+ import { markLocked, refreshAuth } from "@/lib/auth";
15
+ import type { ClientMessage, ServerMessage } from "../ws-protocol";
16
+
17
+ /** Kode close server: sesi login dicabut / terkunci (lihat `app.ts`). */
18
+ const WS_CLOSE_LOCKED = 4401;
19
+
20
+ export type WsConnectionStatus = "idle" | "connecting" | "connected" | "reconnecting" | "closed";
21
+
22
+ export interface UseWebSocketOptions {
23
+ /** URL WebSocket; default: `ws(s)://<host>/ws`. */
24
+ url?: string;
25
+ /** Dipanggil untuk setiap pesan Server -> Client. */
26
+ onMessage?: (msg: ServerMessage) => void;
27
+ /** Jeda reconnect setelah koneksi putus tak terduga (ms). */
28
+ reconnectDelayMs?: number;
29
+ }
30
+
31
+ export interface UseWebSocketResult {
32
+ status: WsConnectionStatus;
33
+ /** Buka/reattach ke Session (kirim pesan `attach`). */
34
+ attach(sessionId: string): void;
35
+ /**
36
+ * Kirim pesan Client -> Server sesuai protokol ws-protocol.ts.
37
+ * `false` bila socket belum/tidak terbuka (pesan tidak terkirim).
38
+ */
39
+ send(msg: ClientMessage): boolean;
40
+ /** Tutup koneksi secara manual (tanpa reconnect otomatis). */
41
+ disconnect(): void;
42
+ }
43
+
44
+ function defaultWsUrl(): string | null {
45
+ if (typeof location === "undefined") return null;
46
+ const proto = location.protocol === "https:" ? "wss" : "ws";
47
+ return `${proto}://${location.host}/ws`;
48
+ }
49
+
50
+ export function useWebSocket(opts: UseWebSocketOptions = {}): UseWebSocketResult {
51
+ const { onMessage, reconnectDelayMs = 1500, url } = opts;
52
+ const [status, setStatus] = useState<WsConnectionStatus>("idle");
53
+
54
+ const wsRef = useRef<WebSocket | null>(null);
55
+ const sessionRef = useRef<string | null>(null);
56
+ const manualRef = useRef(false);
57
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
58
+ /** Socket saat ini sempat terbuka (membedakan putus vs upgrade ditolak). */
59
+ const openedRef = useRef(false);
60
+ /** Kegagalan membuka koneksi beruntun. */
61
+ const failuresRef = useRef(0);
62
+ const onMessageRef = useRef(onMessage);
63
+ onMessageRef.current = onMessage;
64
+
65
+ const buildUrl = useCallback(() => url ?? defaultWsUrl(), [url]);
66
+
67
+ const open = useCallback(() => {
68
+ if (wsRef.current) return;
69
+ const target = buildUrl();
70
+ if (!target) return;
71
+
72
+ manualRef.current = false;
73
+ setStatus("connecting");
74
+ const ws = new WebSocket(target);
75
+ wsRef.current = ws;
76
+
77
+ // Handler socket lama diabaikan: `disconnect()` + `attach()` beruntun
78
+ // (mis. StrictMode / remount) membuat socket baru sebelum `close` socket
79
+ // lama tiba. Tanpa guard ini `onclose` lama menimpa `wsRef` socket baru
80
+ // dengan null sehingga `send()` diam-diam membuang pesan.
81
+ ws.onopen = () => {
82
+ if (wsRef.current !== ws) return;
83
+ openedRef.current = true;
84
+ failuresRef.current = 0;
85
+ setStatus("connected");
86
+ // Re-attach otomatis setelah reconnect (Requirement 4.1). `!== null`
87
+ // (bukan truthy) agar mode daftar (`attach("")`) ikut dikirim ulang.
88
+ const sid = sessionRef.current;
89
+ if (sid !== null)
90
+ ws.send(JSON.stringify({ type: "attach", sessionId: sid } satisfies ClientMessage));
91
+ };
92
+ ws.onmessage = (event) => {
93
+ if (wsRef.current !== ws) return;
94
+ try {
95
+ const msg = JSON.parse(String(event.data)) as ServerMessage;
96
+ onMessageRef.current?.(msg);
97
+ } catch {
98
+ // Abaikan frame yang bukan JSON valid.
99
+ }
100
+ };
101
+ ws.onerror = () => {
102
+ // Penanganan dilakukan di onclose.
103
+ };
104
+ ws.onclose = (event) => {
105
+ if (wsRef.current !== ws) return;
106
+ wsRef.current = null;
107
+ if (manualRef.current) {
108
+ setStatus("closed");
109
+ return;
110
+ }
111
+ // Server menutup karena sesi MILIK SOCKET INI berakhir. Itu belum tentu
112
+ // berarti perangkat terkunci: setelah ganti kunci, sesi lama dicabut
113
+ // tapi cookie sudah berisi sesi baru. Cek ke server dulu — terkunci ->
114
+ // lock screen; masih valid -> sambung ulang dengan cookie baru.
115
+ if (event.code === WS_CLOSE_LOCKED) {
116
+ setStatus("reconnecting");
117
+ void refreshAuth().then((st) => {
118
+ if (manualRef.current) return;
119
+ if (st?.protected && !st.authenticated) {
120
+ manualRef.current = true;
121
+ setStatus("closed");
122
+ markLocked();
123
+ return;
124
+ }
125
+ open();
126
+ });
127
+ return;
128
+ }
129
+ // Upgrade ditolak (401) tampak sebagai putus biasa di browser; cek
130
+ // status kunci agar tidak reconnect tanpa akhir saat terkunci.
131
+ if (!openedRef.current) {
132
+ failuresRef.current += 1;
133
+ if (failuresRef.current >= 2) {
134
+ void refreshAuth().then((st) => {
135
+ if (st?.protected && !st.authenticated) {
136
+ manualRef.current = true;
137
+ setStatus("closed");
138
+ markLocked();
139
+ }
140
+ });
141
+ }
142
+ }
143
+ openedRef.current = false;
144
+ setStatus("reconnecting");
145
+ timerRef.current = setTimeout(() => {
146
+ timerRef.current = null;
147
+ open();
148
+ }, reconnectDelayMs);
149
+ };
150
+ }, [buildUrl, reconnectDelayMs]);
151
+
152
+ const send = useCallback((msg: ClientMessage): boolean => {
153
+ const ws = wsRef.current;
154
+ if (!ws || ws.readyState !== WebSocket.OPEN) return false;
155
+ ws.send(JSON.stringify(msg));
156
+ return true;
157
+ }, []);
158
+
159
+ const attach = useCallback(
160
+ (sessionId: string) => {
161
+ sessionRef.current = sessionId;
162
+ if (!wsRef.current) {
163
+ open();
164
+ return;
165
+ }
166
+ if (wsRef.current.readyState === WebSocket.OPEN) {
167
+ wsRef.current.send(JSON.stringify({ type: "attach", sessionId } satisfies ClientMessage));
168
+ }
169
+ // Bila masih connecting, onopen akan mengirim attach otomatis.
170
+ },
171
+ [open],
172
+ );
173
+
174
+ const disconnect = useCallback(() => {
175
+ manualRef.current = true;
176
+ if (timerRef.current) {
177
+ clearTimeout(timerRef.current);
178
+ timerRef.current = null;
179
+ }
180
+ wsRef.current?.close();
181
+ wsRef.current = null;
182
+ setStatus("closed");
183
+ }, []);
184
+
185
+ // Bersihkan koneksi & timer saat komponen unmount.
186
+ useEffect(() => {
187
+ return () => {
188
+ manualRef.current = true;
189
+ if (timerRef.current) clearTimeout(timerRef.current);
190
+ wsRef.current?.close();
191
+ wsRef.current = null;
192
+ };
193
+ }, []);
194
+
195
+ return { status, attach, send, disconnect };
196
+ }
package/src/index.css ADDED
@@ -0,0 +1,27 @@
1
+ @import "../styles/globals.css";
2
+
3
+ @layer base {
4
+ :root {
5
+ @apply font-sans;
6
+ }
7
+
8
+ html {
9
+ @apply overflow-x-hidden;
10
+ }
11
+
12
+ /* Tanpa display: grid — auto track grid mengikuti max-content item
13
+ terlebar sehingga di layar HP seluruh halaman ikut selebar konten
14
+ terlebar lalu terpotong (overflow horizontal 414px -> 465px pada bug
15
+ #25.1). Shell app sudah h-dvh w-full, jadi body cukup block biasa. */
16
+ body {
17
+ @apply block relative m-0 min-h-screen min-w-[320px] bg-background text-foreground overflow-x-hidden;
18
+ }
19
+ }
20
+
21
+ @media (prefers-reduced-motion) {
22
+ *,
23
+ ::before,
24
+ ::after {
25
+ animation: none !important;
26
+ }
27
+ }
package/src/index.html ADDED
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/svg+xml" href="./logo.svg" />
7
+ <meta name="theme-color" content="#0a0a0a" />
8
+ <title>KCG Code</title>
9
+ <script type="module" src="./frontend.tsx" async></script>
10
+ </head>
11
+ <body class="font-sans">
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
package/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * KCG Code — entry server dev (`bun dev` / `bun start`).
3
+ *
4
+ * Mode runtime default `dev`: config prioritaskan `./kcg-code.config.json`,
5
+ * data di `./data/`, HMR aktif. Binary global `kcgcode` memanggil
6
+ * `setRunMode("cli")` di `bin/kcgcode.ts` (data di `~/.kcgcode`).
7
+ *
8
+ * Wiring `Bun.serve` diimplementasikan di `src/server/app.ts`
9
+ * (`createKcgServer`) agar dapat diuji secara e2e (task 20.2). File ini:
10
+ * - mendaftarkan rute halaman yang menyajikan shell SPA (`index.html`)
11
+ * secara eksplisit — `spaPaths` di bawah, pola yang sama dengan daftar
12
+ * rute SPA di entry kcgcode; path lain di luar daftar mengembalikan 404
13
+ * (bukan catch-all seperti sebelumnya),
14
+ * - memulai server,
15
+ * - mendaftarkan handler shutdown SIGINT/SIGTERM yang menyimpan status
16
+ * Session `running` dalam anggaran 5 detik sebelum berhenti (Req 2.3).
17
+ */
18
+ import index from "./index.html";
19
+ import { setRunMode } from "./runtime";
20
+ import { createKcgServer } from "./server/app";
21
+
22
+ // Entry ini = development di repo. (CLI global men-set "cli" di bin/.)
23
+ setRunMode("dev");
24
+
25
+ if (import.meta.main) {
26
+ const app = createKcgServer({
27
+ spa: index,
28
+ // Rute halaman SPA: `/`, `/projects/:projectId`, dan Session view
29
+ // (`/projects/:projectId/sessions/:sessionId` — tercakup wildcard).
30
+ spaPaths: ["/", "/projects", "/projects/*", "/skills", "/settings", "/settings/*"],
31
+ });
32
+ console.log(`🚀 KCG Code berjalan di ${app.server.url}`);
33
+
34
+ let shuttingDown = false;
35
+ async function shutdown(signal: string): Promise<void> {
36
+ if (shuttingDown) return;
37
+ shuttingDown = true;
38
+ console.log(`\n${signal} diterima — menyimpan status Session lalu menghentikan server...`);
39
+ try {
40
+ await app.close();
41
+ } catch (err) {
42
+ console.error("Error saat shutdown:", err);
43
+ }
44
+ process.exit(0);
45
+ }
46
+
47
+ process.on("SIGINT", () => void shutdown("SIGINT"));
48
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
49
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ attachmentKind,
4
+ fileExtension,
5
+ formatBytes,
6
+ modelFacingMime,
7
+ normalizeMime,
8
+ } from "../attachments";
9
+
10
+ describe("attachmentKind", () => {
11
+ test("gambar umum & PDF dikenali dari mime", () => {
12
+ expect(attachmentKind("image/png", "a.png")).toBe("image");
13
+ expect(attachmentKind("image/webp", "a.webp")).toBe("image");
14
+ expect(attachmentKind("application/pdf", "doc.pdf")).toBe("pdf");
15
+ });
16
+
17
+ test("file kode dikenali dari ekstensi walau mime salah / kosong", () => {
18
+ expect(attachmentKind("video/mp2t", "main.ts")).toBe("text");
19
+ expect(attachmentKind("", "lib.rs")).toBe("text");
20
+ expect(attachmentKind("", "Dockerfile")).toBe("text");
21
+ expect(attachmentKind("application/json", "data")).toBe("text");
22
+ expect(attachmentKind("text/csv", "x.csv")).toBe("text");
23
+ });
24
+
25
+ test("SVG diperlakukan teks, bukan gambar pratinjau", () => {
26
+ expect(attachmentKind("image/svg+xml", "logo.svg")).toBe("text");
27
+ });
28
+
29
+ test("lainnya biner", () => {
30
+ expect(attachmentKind("application/zip", "a.zip")).toBe("binary");
31
+ expect(attachmentKind("image/heic", "p.heic")).toBe("binary");
32
+ expect(attachmentKind("", "blob")).toBe("binary");
33
+ });
34
+ });
35
+
36
+ describe("modelFacingMime", () => {
37
+ test("teks dikirim sebagai text/plain, biner tidak dikirim", () => {
38
+ expect(modelFacingMime("text/csv", "x.csv")).toBe("text/plain");
39
+ expect(modelFacingMime("image/png", "a.png")).toBe("image/png");
40
+ expect(modelFacingMime("application/pdf", "a.pdf")).toBe("application/pdf");
41
+ expect(modelFacingMime("application/zip", "a.zip")).toBeNull();
42
+ });
43
+ });
44
+
45
+ test("normalizeMime menolak nilai yang bisa menyuntik header", () => {
46
+ expect(normalizeMime("Text/Plain")).toBe("text/plain");
47
+ expect(normalizeMime("")).toBe("application/octet-stream");
48
+ expect(normalizeMime("text/html\r\nx-evil: 1")).toBe("application/octet-stream");
49
+ expect(normalizeMime("text/plain; charset=utf-8")).toBe("application/octet-stream");
50
+ });
51
+
52
+ test("fileExtension & formatBytes", () => {
53
+ expect(fileExtension("a/b/c.TAR.GZ")).toBe("gz");
54
+ expect(fileExtension(".env")).toBe("");
55
+ expect(formatBytes(512)).toBe("512 B");
56
+ expect(formatBytes(1536)).toBe("1.5 KB");
57
+ expect(formatBytes(20 * 1024 * 1024)).toBe("20 MB");
58
+ });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Katalog avatar bawaan: setiap id preset (dipakai server) wajib punya
3
+ * pemetaan ikon di FE, dan sebaliknya.
4
+ */
5
+ import { describe, expect, test } from "bun:test";
6
+ import { AVATAR_PRESETS } from "@/components/auth/avatar-icons";
7
+ import { AVATAR_PRESET_IDS, isAvatarPresetId } from "../avatar-presets";
8
+
9
+ describe("avatar presets", () => {
10
+ test("id server == pemetaan ikon FE", () => {
11
+ expect(Object.keys(AVATAR_PRESETS).sort()).toEqual([...AVATAR_PRESET_IDS].sort());
12
+ for (const id of AVATAR_PRESET_IDS) {
13
+ expect(typeof AVATAR_PRESETS[id].Icon).toBe("object");
14
+ expect(AVATAR_PRESETS[id].label.length).toBeGreaterThan(0);
15
+ }
16
+ });
17
+
18
+ test("isAvatarPresetId hanya menerima id katalog", () => {
19
+ expect(isAvatarPresetId("cat")).toBe(true);
20
+ expect(isAvatarPresetId("Cat")).toBe(false);
21
+ expect(isAvatarPresetId("../x")).toBe(false);
22
+ expect(isAvatarPresetId(1)).toBe(false);
23
+ expect(new Set(AVATAR_PRESET_IDS).size).toBe(AVATAR_PRESET_IDS.length);
24
+ });
25
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Property test collapsible thinking block (task 24.8).
3
+ *
4
+ * // Feature: kcg-code, Property 25: Toggle collapsible thinking independen antar pesan
5
+ * // Validates: Requirements 8.3
6
+ *
7
+ * For any himpunan pesan dan sembarang urutan aksi toggle collapsible pada
8
+ * masing-masing pesan, status tampil/tersembunyi setiap pesan setelah seluruh
9
+ * aksi = hasil XOR paritas jumlah toggle yang diterapkan pada pesan itu
10
+ * sendiri terhadap nilai default `true` (expanded), dan tidak terpengaruh
11
+ * oleh toggle pada pesan lain.
12
+ */
13
+ import { describe, expect, test } from "bun:test";
14
+ import fc from "fast-check";
15
+
16
+ import {
17
+ extendCollapsed,
18
+ initialCollapsedState,
19
+ initialCollapsibleState,
20
+ isCollapsibleExpanded,
21
+ toggleCollapsible,
22
+ } from "@/lib/collapsible";
23
+
24
+ describe("collapsible-state — Property 25", () => {
25
+ // Feature: kcg-code, Property 25: Toggle collapsible thinking independen antar pesan
26
+ test("toggle independen antar pesan terhadap default expanded (Req 8.3)", () => {
27
+ fc.assert(
28
+ fc.property(
29
+ // Himpunan identitas pesan unik.
30
+ fc.uniqueArray(fc.string({ maxLength: 12 }), { maxLength: 10 }),
31
+ // Sembarang urutan aksi: indeks pesan yang di-toggle (boleh acak/berulang).
32
+ fc.array(fc.nat({ max: 30 }), { maxLength: 80 }),
33
+ (ids, idxActions) => {
34
+ let state = initialCollapsibleState(ids);
35
+ const toggles = new Map<string, number>();
36
+
37
+ for (const idx of idxActions) {
38
+ if (ids.length === 0) break;
39
+ const id = ids[idx % ids.length];
40
+ if (!id) continue;
41
+ state = toggleCollapsible(state, id);
42
+ toggles.set(id, (toggles.get(id) ?? 0) + 1);
43
+ }
44
+
45
+ // Status akhir tiap pesan hanya bergantung pada paritas toggle
46
+ // milik pesan itu sendiri terhadap default true (Req 8.3,
47
+ // Property 25) — tidak terpengaruh pesan lain.
48
+ for (const id of ids) {
49
+ const k = toggles.get(id) ?? 0;
50
+ expect(isCollapsibleExpanded(state, id)).toBe(k % 2 === 0);
51
+ }
52
+ },
53
+ ),
54
+ { numRuns: 100 },
55
+ );
56
+ });
57
+
58
+ test("pesan baru tanpa state eksplisit default expanded (Req 8.3)", () => {
59
+ const state = initialCollapsibleState(["a"]);
60
+ expect(isCollapsibleExpanded(state, "a")).toBe(true);
61
+ expect(isCollapsibleExpanded(state, "b")).toBe(true);
62
+ });
63
+
64
+ // Reason: render Thinking sekarang default collapsed (dibatasi tinggi),
65
+ // dapat di-expand per part — perilaku baru dari sesi headless.
66
+ test("initialCollapsedState: seluruh reasoning default tertutup", () => {
67
+ const state = initialCollapsedState(["m1:0", "m1:1"]);
68
+ expect(isCollapsibleExpanded(state, "m1:0")).toBe(false);
69
+ expect(isCollapsibleExpanded(state, "m1:1")).toBe(false);
70
+ });
71
+
72
+ test("extendCollapsed: key baru tertutup, key yang sudah ada dipertahankan", () => {
73
+ let state = initialCollapsedState(["m1:0"]);
74
+ state = toggleCollapsible(state, "m1:0"); // user expand m1:0
75
+ expect(isCollapsibleExpanded(state, "m1:0")).toBe(true);
76
+
77
+ // Pesan baru datang -> reasoning-nya collapsed; m1:0 tetap expanded.
78
+ state = extendCollapsed(state, ["m2:0", "m2:1"]);
79
+ expect(isCollapsibleExpanded(state, "m1:0")).toBe(true);
80
+ expect(isCollapsibleExpanded(state, "m2:0")).toBe(false);
81
+ expect(isCollapsibleExpanded(state, "m2:1")).toBe(false);
82
+ expect(isCollapsibleExpanded(state, "m3:0")).toBe(true); // tak dikenal -> default
83
+ });
84
+
85
+ test("extendCollapsed: daftar kosong tidak mengubah state", () => {
86
+ const state = initialCollapsedState(["a"]);
87
+ expect(extendCollapsed(state, [])).toBe(state);
88
+ });
89
+
90
+ test("urutan toggle sederhana membalik status pesan terkait saja", () => {
91
+ let state = initialCollapsibleState(["a", "b"]);
92
+ state = toggleCollapsible(state, "a");
93
+ expect(isCollapsibleExpanded(state, "a")).toBe(false);
94
+ expect(isCollapsibleExpanded(state, "b")).toBe(true);
95
+
96
+ state = toggleCollapsible(state, "b");
97
+ state = toggleCollapsible(state, "b");
98
+ expect(isCollapsibleExpanded(state, "b")).toBe(true);
99
+ expect(isCollapsibleExpanded(state, "a")).toBe(false);
100
+ });
101
+ });