kcg-code 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/README.md +228 -2
  2. package/bin/kcgcode.ts +13 -0
  3. package/bun-env.d.ts +19 -0
  4. package/kcg-code.config.example.json +3 -0
  5. package/package.json +83 -4
  6. package/public/logo.svg +1 -0
  7. package/public/manifest.json +18 -0
  8. package/public/sw.js +49 -0
  9. package/src/App.tsx +39 -0
  10. package/src/__tests__/manifest.test.ts +61 -0
  11. package/src/build-config.ts +9 -0
  12. package/src/cli/__tests__/dashboard.test.ts +62 -0
  13. package/src/cli/args.ts +123 -0
  14. package/src/cli/commands.ts +174 -0
  15. package/src/cli/dashboard.tsx +343 -0
  16. package/src/cli/index.ts +37 -0
  17. package/src/cli/theme.ts +100 -0
  18. package/src/components/auth/AuthGate.tsx +182 -0
  19. package/src/components/auth/AvatarEditor.tsx +236 -0
  20. package/src/components/auth/LockScreen.tsx +714 -0
  21. package/src/components/auth/ProfileAvatar.tsx +60 -0
  22. package/src/components/auth/WelcomeScreen.tsx +672 -0
  23. package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
  24. package/src/components/auth/avatar-icons.tsx +114 -0
  25. package/src/components/layout/AppShell.tsx +142 -0
  26. package/src/components/layout/AppSidebar.tsx +419 -0
  27. package/src/components/layout/SearchDialog.tsx +189 -0
  28. package/src/components/layout/SidebarAccount.tsx +111 -0
  29. package/src/components/layout/SidebarSessionMenu.tsx +163 -0
  30. package/src/components/layout/ThemeToggle.tsx +67 -0
  31. package/src/components/projects/FolderBrowser.tsx +142 -0
  32. package/src/components/projects/HomeChat.tsx +250 -0
  33. package/src/components/projects/NewProjectDialog.tsx +249 -0
  34. package/src/components/projects/PanelSection.tsx +102 -0
  35. package/src/components/projects/ProjectInstructions.tsx +229 -0
  36. package/src/components/projects/ProjectRow.tsx +58 -0
  37. package/src/components/projects/ProjectSidePanel.tsx +448 -0
  38. package/src/components/sessions/AgentModeList.tsx +265 -0
  39. package/src/components/sessions/AgentPicker.tsx +123 -0
  40. package/src/components/sessions/AssistantTurn.tsx +145 -0
  41. package/src/components/sessions/AttachmentTile.tsx +161 -0
  42. package/src/components/sessions/CodeBlock.tsx +107 -0
  43. package/src/components/sessions/ComposerActionSheet.tsx +96 -0
  44. package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
  45. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +72 -0
  46. package/src/components/sessions/ContextPanel.tsx +561 -0
  47. package/src/components/sessions/DropPlate.tsx +165 -0
  48. package/src/components/sessions/FileDropOverlay.tsx +232 -0
  49. package/src/components/sessions/FileMentionDropdown.tsx +47 -0
  50. package/src/components/sessions/ImageLightbox.tsx +173 -0
  51. package/src/components/sessions/MarkdownContent.tsx +187 -0
  52. package/src/components/sessions/Mascot.tsx +47 -0
  53. package/src/components/sessions/MessageActions.tsx +60 -0
  54. package/src/components/sessions/MessageRail.tsx +121 -0
  55. package/src/components/sessions/ModelPicker.tsx +209 -0
  56. package/src/components/sessions/ModelSearchList.tsx +151 -0
  57. package/src/components/sessions/NewSessionDialog.tsx +189 -0
  58. package/src/components/sessions/PendingAttachments.tsx +127 -0
  59. package/src/components/sessions/PromptCard.tsx +1085 -0
  60. package/src/components/sessions/PromptPanel.tsx +112 -0
  61. package/src/components/sessions/SessionComposer.tsx +640 -0
  62. package/src/components/sessions/SessionHeader.tsx +175 -0
  63. package/src/components/sessions/SessionList.tsx +396 -0
  64. package/src/components/sessions/SessionRow.tsx +137 -0
  65. package/src/components/sessions/SessionTimeline.tsx +275 -0
  66. package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
  67. package/src/components/sessions/SessionView.tsx +236 -0
  68. package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
  69. package/src/components/sessions/UserMessage.tsx +177 -0
  70. package/src/components/sessions/__tests__/ContextPanel.test.tsx +220 -0
  71. package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
  72. package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
  73. package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
  74. package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
  75. package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
  76. package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
  77. package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
  78. package/src/components/settings/AboutGroup.tsx +170 -0
  79. package/src/components/settings/AccountGroup.tsx +122 -0
  80. package/src/components/settings/DevicesGroup.tsx +151 -0
  81. package/src/components/settings/LhrRemoteAccess.tsx +262 -0
  82. package/src/components/settings/PasskeysGroup.tsx +551 -0
  83. package/src/components/settings/RemoteAccessGroup.tsx +1226 -0
  84. package/src/components/settings/SecurityGroup.tsx +566 -0
  85. package/src/components/settings/WorkspaceGroup.tsx +370 -0
  86. package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
  87. package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
  88. package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
  89. package/src/components/settings/prefs.tsx +265 -0
  90. package/src/components/skills/InstallNotifications.tsx +325 -0
  91. package/src/components/skills/InstallSkillDialog.tsx +233 -0
  92. package/src/components/skills/SkillCards.tsx +210 -0
  93. package/src/components/skills/SkillsProjectPicker.tsx +178 -0
  94. package/src/components/ui/alert-dialog.tsx +179 -0
  95. package/src/components/ui/alert.tsx +60 -0
  96. package/src/components/ui/badge.tsx +46 -0
  97. package/src/components/ui/bubble.tsx +125 -0
  98. package/src/components/ui/button.tsx +62 -0
  99. package/src/components/ui/card.tsx +75 -0
  100. package/src/components/ui/collapsible.tsx +19 -0
  101. package/src/components/ui/dialog.tsx +142 -0
  102. package/src/components/ui/dropdown-menu.tsx +225 -0
  103. package/src/components/ui/empty.tsx +94 -0
  104. package/src/components/ui/field.tsx +234 -0
  105. package/src/components/ui/input-group.tsx +161 -0
  106. package/src/components/ui/input.tsx +21 -0
  107. package/src/components/ui/label.tsx +19 -0
  108. package/src/components/ui/message-scroller.tsx +131 -0
  109. package/src/components/ui/message.tsx +85 -0
  110. package/src/components/ui/popover.tsx +46 -0
  111. package/src/components/ui/select.tsx +172 -0
  112. package/src/components/ui/separator.tsx +28 -0
  113. package/src/components/ui/sheet.tsx +138 -0
  114. package/src/components/ui/sidebar.tsx +699 -0
  115. package/src/components/ui/skeleton.tsx +15 -0
  116. package/src/components/ui/sonner.tsx +45 -0
  117. package/src/components/ui/spinner.tsx +16 -0
  118. package/src/components/ui/switch.tsx +32 -0
  119. package/src/components/ui/textarea.tsx +18 -0
  120. package/src/components/ui/tooltip.tsx +51 -0
  121. package/src/config.ts +85 -0
  122. package/src/db.ts +146 -0
  123. package/src/frontend.tsx +51 -0
  124. package/src/hooks/__tests__/useMention.test.ts +79 -0
  125. package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
  126. package/src/hooks/__tests__/useRouter.test.ts +17 -0
  127. package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
  128. package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
  129. package/src/hooks/__tests__/useTheme.test.ts +118 -0
  130. package/src/hooks/use-mobile.ts +16 -0
  131. package/src/hooks/useAgentPicker.ts +97 -0
  132. package/src/hooks/useMediaQuery.ts +49 -0
  133. package/src/hooks/useMention.ts +236 -0
  134. package/src/hooks/useProjects.ts +88 -0
  135. package/src/hooks/useResyncOnReconnect.ts +28 -0
  136. package/src/hooks/useRouter.ts +92 -0
  137. package/src/hooks/useSessionChat.ts +427 -0
  138. package/src/hooks/useSessionContext.ts +106 -0
  139. package/src/hooks/useSessionList.ts +234 -0
  140. package/src/hooks/useSidebarData.ts +108 -0
  141. package/src/hooks/useSmoothText.ts +122 -0
  142. package/src/hooks/useTheme.ts +164 -0
  143. package/src/hooks/useWebSocket.ts +196 -0
  144. package/src/index.css +27 -0
  145. package/src/index.html +14 -0
  146. package/src/index.ts +49 -0
  147. package/src/lib/__tests__/attachments.test.ts +58 -0
  148. package/src/lib/__tests__/avatar-presets.test.ts +25 -0
  149. package/src/lib/__tests__/collapsible.test.ts +101 -0
  150. package/src/lib/__tests__/composer.test.ts +118 -0
  151. package/src/lib/__tests__/highlight.test.ts +42 -0
  152. package/src/lib/__tests__/home-chat.test.ts +64 -0
  153. package/src/lib/__tests__/lock-screen.test.ts +64 -0
  154. package/src/lib/__tests__/model-picker.test.ts +205 -0
  155. package/src/lib/__tests__/onboarding.test.ts +41 -0
  156. package/src/lib/__tests__/permission-info.test.ts +61 -0
  157. package/src/lib/__tests__/project-extensions.test.ts +52 -0
  158. package/src/lib/__tests__/project-overview.test.ts +173 -0
  159. package/src/lib/__tests__/references.test.ts +121 -0
  160. package/src/lib/__tests__/routes.test.ts +134 -0
  161. package/src/lib/__tests__/session-summary.test.ts +115 -0
  162. package/src/lib/__tests__/session-title.test.ts +58 -0
  163. package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
  164. package/src/lib/__tests__/skill-installs.test.ts +134 -0
  165. package/src/lib/__tests__/skills-catalog.test.ts +96 -0
  166. package/src/lib/__tests__/tone.test.ts +37 -0
  167. package/src/lib/__tests__/turns-delta.test.ts +55 -0
  168. package/src/lib/api.ts +306 -0
  169. package/src/lib/attachments.ts +203 -0
  170. package/src/lib/auth.ts +92 -0
  171. package/src/lib/avatar-presets.ts +31 -0
  172. package/src/lib/collapsible.ts +75 -0
  173. package/src/lib/composer.ts +89 -0
  174. package/src/lib/data-events.ts +17 -0
  175. package/src/lib/headlines.ts +30 -0
  176. package/src/lib/highlight.ts +186 -0
  177. package/src/lib/layout.ts +20 -0
  178. package/src/lib/lock-screen.ts +54 -0
  179. package/src/lib/model-picker.ts +133 -0
  180. package/src/lib/onboarding.ts +38 -0
  181. package/src/lib/passkeys.ts +113 -0
  182. package/src/lib/pending-prompt.ts +53 -0
  183. package/src/lib/permission-info.ts +101 -0
  184. package/src/lib/project-extensions.ts +74 -0
  185. package/src/lib/project-overview.ts +117 -0
  186. package/src/lib/prompts.ts +87 -0
  187. package/src/lib/references.ts +106 -0
  188. package/src/lib/routes.ts +115 -0
  189. package/src/lib/session-handoff.ts +25 -0
  190. package/src/lib/session-status.ts +45 -0
  191. package/src/lib/session-summary.ts +68 -0
  192. package/src/lib/session-title.ts +63 -0
  193. package/src/lib/sidebar-groups.ts +78 -0
  194. package/src/lib/skill-installs.ts +360 -0
  195. package/src/lib/skills-catalog.ts +99 -0
  196. package/src/lib/time.ts +22 -0
  197. package/src/lib/tone.ts +80 -0
  198. package/src/lib/turns.ts +320 -0
  199. package/src/lib/utils.ts +6 -0
  200. package/src/logo.svg +31 -0
  201. package/src/pages/projects/ProjectDetailPage.tsx +130 -0
  202. package/src/pages/projects/ProjectsPage.tsx +97 -0
  203. package/src/pages/sessions/SessionPage.tsx +122 -0
  204. package/src/pages/settings/SettingsPage.tsx +299 -0
  205. package/src/pages/settings/__tests__/SettingsPage.test.ts +9 -0
  206. package/src/pages/skills/SkillsPage.tsx +701 -0
  207. package/src/paths.ts +121 -0
  208. package/src/runtime.ts +24 -0
  209. package/src/server/__tests__/app.test.ts +1031 -0
  210. package/src/server/__tests__/auth.test.ts +370 -0
  211. package/src/server/__tests__/config.test.ts +101 -0
  212. package/src/server/__tests__/db.test.ts +485 -0
  213. package/src/server/__tests__/host.test.ts +14 -0
  214. package/src/server/__tests__/tunnel-config.test.ts +38 -0
  215. package/src/server/app.ts +415 -0
  216. package/src/server/db/auth.ts +311 -0
  217. package/src/server/db/messages.ts +50 -0
  218. package/src/server/db/projects.ts +106 -0
  219. package/src/server/db/prompts.ts +78 -0
  220. package/src/server/db/rows.ts +150 -0
  221. package/src/server/db/schema.ts +221 -0
  222. package/src/server/db/sessions.ts +239 -0
  223. package/src/server/db/tunnel.ts +124 -0
  224. package/src/server/host.ts +12 -0
  225. package/src/server/result.ts +15 -0
  226. package/src/server/routes/auth-guard.ts +156 -0
  227. package/src/server/routes/auth.routes.ts +287 -0
  228. package/src/server/routes/helpers.ts +165 -0
  229. package/src/server/routes/meta.routes.ts +24 -0
  230. package/src/server/routes/passkeys.routes.ts +161 -0
  231. package/src/server/routes/projects.routes.ts +157 -0
  232. package/src/server/routes/sessions.routes.ts +197 -0
  233. package/src/server/routes/skills.routes.ts +117 -0
  234. package/src/server/routes/tunnel.routes.ts +132 -0
  235. package/src/server/routes/types.ts +35 -0
  236. package/src/server/routes/uploads.routes.ts +54 -0
  237. package/src/server/routes/workspace.routes.ts +127 -0
  238. package/src/server/services/__tests__/app-info.test.ts +18 -0
  239. package/src/server/services/__tests__/attachments.test.ts +159 -0
  240. package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
  241. package/src/server/services/__tests__/auth.test.ts +341 -0
  242. package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
  243. package/src/server/services/__tests__/git-info.test.ts +74 -0
  244. package/src/server/services/__tests__/opencode.test.ts +320 -0
  245. package/src/server/services/__tests__/project-manager.test.ts +190 -0
  246. package/src/server/services/__tests__/sandbox.test.ts +141 -0
  247. package/src/server/services/__tests__/session-manager.test.ts +3469 -0
  248. package/src/server/services/__tests__/skill-install-jobs.test.ts +226 -0
  249. package/src/server/services/__tests__/skills-registry.test.ts +359 -0
  250. package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
  251. package/src/server/services/__tests__/tunnel.test.ts +367 -0
  252. package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
  253. package/src/server/services/__tests__/workspace.test.ts +249 -0
  254. package/src/server/services/app-info.ts +29 -0
  255. package/src/server/services/attachments.ts +193 -0
  256. package/src/server/services/auth-passkeys.ts +459 -0
  257. package/src/server/services/auth.ts +633 -0
  258. package/src/server/services/frpc-installer.ts +183 -0
  259. package/src/server/services/git-info.ts +87 -0
  260. package/src/server/services/opencode-client.ts +714 -0
  261. package/src/server/services/opencode-server.ts +319 -0
  262. package/src/server/services/opencode-sse.ts +82 -0
  263. package/src/server/services/project-manager.ts +147 -0
  264. package/src/server/services/sandbox.ts +83 -0
  265. package/src/server/services/session-events.ts +256 -0
  266. package/src/server/services/session-manager.ts +1374 -0
  267. package/src/server/services/skill-install-jobs.ts +262 -0
  268. package/src/server/services/skills-registry.ts +468 -0
  269. package/src/server/services/tunnel-api.ts +207 -0
  270. package/src/server/services/tunnel-config.ts +45 -0
  271. package/src/server/services/tunnel-lhr.ts +387 -0
  272. package/src/server/services/tunnel.ts +633 -0
  273. package/src/server/services/turn-stream.ts +339 -0
  274. package/src/server/services/websocket-gateway.ts +374 -0
  275. package/src/server/services/workspace.ts +223 -0
  276. package/src/server/services/ws-transport.ts +121 -0
  277. package/src/types/context.ts +69 -0
  278. package/src/types/index.ts +13 -0
  279. package/src/types/message.ts +58 -0
  280. package/src/types/project.ts +16 -0
  281. package/src/types/prompt.ts +118 -0
  282. package/src/types/session.ts +52 -0
  283. package/src/types/turns.ts +47 -0
  284. package/src/ws-protocol.ts +93 -0
  285. package/styles/globals.css +311 -0
  286. package/tsconfig.json +37 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Test perilaku `useSmoothText` di React SUNGGUHAN (StrictMode), tanpa DOM.
3
+ *
4
+ * Renderer minimal dibangun dari `react-reconciler` (sudah terpasang sebagai
5
+ * dependensi transitif) — cukup untuk menjalankan hook, effect, dan
6
+ * StrictMode (effect dipasang -> dibongkar -> dipasang ulang). rAF diganti
7
+ * antrean manual agar frame bisa "dijalankan" deterministik.
8
+ *
9
+ * Regresi yang dijaga: di StrictMode loop rAF pernah mati setelah remount
10
+ * simulasi, sehingga teks baru muncul sekaligus saat streaming selesai.
11
+ */
12
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
13
+ import { act, createElement, StrictMode } from "react";
14
+ import Reconciler from "react-reconciler";
15
+ import { useSmoothText } from "../useSmoothText";
16
+
17
+ // ---- rAF palsu ----------------------------------------------------------
18
+ let rafQueue = new Map<number, FrameRequestCallback>();
19
+ let rafId = 0;
20
+ let now = 0;
21
+ const g = globalThis as Record<string, unknown>;
22
+ const saved = {
23
+ raf: g.requestAnimationFrame,
24
+ caf: g.cancelAnimationFrame,
25
+ win: g.window,
26
+ actEnv: g.IS_REACT_ACT_ENVIRONMENT,
27
+ };
28
+
29
+ function runFrames(n: number, dt = 16) {
30
+ for (let i = 0; i < n; i++) {
31
+ now += dt;
32
+ const batch = rafQueue;
33
+ rafQueue = new Map();
34
+ act(() => {
35
+ for (const cb of batch.values()) cb(now);
36
+ });
37
+ }
38
+ }
39
+
40
+ beforeEach(() => {
41
+ rafQueue = new Map();
42
+ rafId = 0;
43
+ now = 0;
44
+ g.requestAnimationFrame = (cb: FrameRequestCallback) => {
45
+ rafId += 1;
46
+ rafQueue.set(rafId, cb);
47
+ return rafId;
48
+ };
49
+ g.cancelAnimationFrame = (id: number) => {
50
+ rafQueue.delete(id);
51
+ };
52
+ // `prefersReducedMotion` membaca window.matchMedia.
53
+ g.window = { matchMedia: () => ({ matches: false }) };
54
+ g.IS_REACT_ACT_ENVIRONMENT = true;
55
+ });
56
+
57
+ afterEach(() => {
58
+ g.requestAnimationFrame = saved.raf;
59
+ g.cancelAnimationFrame = saved.caf;
60
+ g.window = saved.win;
61
+ g.IS_REACT_ACT_ENVIRONMENT = saved.actEnv;
62
+ });
63
+
64
+ // ---- renderer tanpa DOM ---------------------------------------------------
65
+ /** Renderer "null": tidak membuat node apa pun, cukup menjalankan komponen. */
66
+ const reconciler = Reconciler({
67
+ supportsMutation: true,
68
+ isPrimaryRenderer: false,
69
+ createInstance: () => ({}),
70
+ createTextInstance: () => ({}),
71
+ appendInitialChild: () => {},
72
+ appendChild: () => {},
73
+ appendChildToContainer: () => {},
74
+ insertBefore: () => {},
75
+ insertInContainerBefore: () => {},
76
+ removeChild: () => {},
77
+ removeChildFromContainer: () => {},
78
+ commitTextUpdate: () => {},
79
+ commitUpdate: () => {},
80
+ finalizeInitialChildren: () => false,
81
+ shouldSetTextContent: () => false,
82
+ getRootHostContext: () => ({}),
83
+ getChildHostContext: () => ({}),
84
+ getPublicInstance: (i: unknown) => i,
85
+ prepareForCommit: () => null,
86
+ resetAfterCommit: () => {},
87
+ preparePortalMount: () => {},
88
+ clearContainer: () => {},
89
+ scheduleTimeout: setTimeout,
90
+ cancelTimeout: clearTimeout,
91
+ noTimeout: -1,
92
+ getCurrentUpdatePriority: () => 32,
93
+ setCurrentUpdatePriority: () => {},
94
+ resolveUpdatePriority: () => 32,
95
+ getInstanceFromNode: () => null,
96
+ beforeActiveInstanceBlur: () => {},
97
+ afterActiveInstanceBlur: () => {},
98
+ prepareScopeUpdate: () => {},
99
+ getInstanceFromScope: () => null,
100
+ detachDeletedInstance: () => {},
101
+ maySuspendCommit: () => false,
102
+ preloadInstance: () => true,
103
+ startSuspendingCommit: () => {},
104
+ suspendInstance: () => {},
105
+ waitForCommitToBeReady: () => null,
106
+ NotPendingTransition: null,
107
+ HostTransitionContext: { $$typeof: Symbol.for("react.context"), _currentValue: null },
108
+ resetFormInstance: () => {},
109
+ requestPostPaintCallback: () => {},
110
+ shouldAttemptEagerTransition: () => false,
111
+ trackSchedulerEvent: () => {},
112
+ resolveEventType: () => null,
113
+ resolveEventTimeStamp: () => -1.1,
114
+ } as never);
115
+
116
+ function mount() {
117
+ let latest = "";
118
+ function Probe({ text, on }: { text: string; on: boolean }) {
119
+ latest = useSmoothText(text, on);
120
+ return null;
121
+ }
122
+ const root = (
123
+ reconciler as never as {
124
+ createContainer: (...a: unknown[]) => unknown;
125
+ }
126
+ ).createContainer(
127
+ {},
128
+ 1,
129
+ null,
130
+ false,
131
+ null,
132
+ "",
133
+ () => {},
134
+ () => {},
135
+ () => {},
136
+ null,
137
+ );
138
+ const render = (text: string, on: boolean) =>
139
+ act(() => {
140
+ reconciler.updateContainer(
141
+ createElement(StrictMode, null, createElement(Probe, { text, on })),
142
+ root as never,
143
+ null,
144
+ null,
145
+ );
146
+ });
147
+ return { render, shown: () => latest };
148
+ }
149
+
150
+ describe("useSmoothText (StrictMode)", () => {
151
+ test("teks tampil bertahap SELAMA streaming, bukan sekaligus di akhir", () => {
152
+ const h = mount();
153
+ h.render("", true);
154
+ h.render("Kucing adalah hewan peliharaan yang sudah lama hidup bersama manusia.", true);
155
+
156
+ runFrames(3);
157
+ const partial = h.shown();
158
+ // Sudah mulai tampil, tapi belum semuanya -> benar-benar bertahap.
159
+ expect(partial.length).toBeGreaterThan(0);
160
+ expect(partial.length).toBeLessThan(70);
161
+
162
+ runFrames(200);
163
+ expect(h.shown()).toBe("Kucing adalah hewan peliharaan yang sudah lama hidup bersama manusia.");
164
+ });
165
+
166
+ test("delta baru yang tiba di tengah animasi ikut dikejar", () => {
167
+ const h = mount();
168
+ h.render("Halo", true);
169
+ runFrames(100);
170
+ expect(h.shown()).toBe("Halo");
171
+
172
+ h.render("Halo, apa kabar hari ini?", true);
173
+ runFrames(1);
174
+ expect(h.shown().length).toBeLessThan("Halo, apa kabar hari ini?".length);
175
+ runFrames(200);
176
+ expect(h.shown()).toBe("Halo, apa kabar hari ini?");
177
+ });
178
+
179
+ test("streaming selesai -> teks penuh seketika", () => {
180
+ const h = mount();
181
+ h.render("", true);
182
+ h.render("Jawaban panjang yang belum sempat tampil semua.", true);
183
+ runFrames(1);
184
+ h.render("Jawaban panjang yang belum sempat tampil semua.", false);
185
+ expect(h.shown()).toBe("Jawaban panjang yang belum sempat tampil semua.");
186
+ });
187
+
188
+ test("tidak streaming (riwayat) -> teks penuh tanpa animasi", () => {
189
+ const h = mount();
190
+ h.render("Pesan lama dari riwayat.", false);
191
+ expect(h.shown()).toBe("Pesan lama dari riwayat.");
192
+ expect(rafQueue.size).toBe(0);
193
+ });
194
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Unit test laju penghalusan teks streaming (`nextSmoothLength`) — logika
3
+ * murni di balik `useSmoothText` (tanpa DOM / rAF).
4
+ */
5
+ import { describe, expect, test } from "bun:test";
6
+ import { nextSmoothLength } from "../useSmoothText";
7
+
8
+ const FRAME = 16;
9
+
10
+ describe("nextSmoothLength", () => {
11
+ test("selalu maju minimal satu karakter per frame", () => {
12
+ expect(nextSmoothLength(0, "abcdef", 1)).toBeGreaterThan(0);
13
+ });
14
+
15
+ test("tidak melewati panjang target", () => {
16
+ expect(nextSmoothLength(3, "abc", FRAME)).toBe(3);
17
+ expect(nextSmoothLength(0, "ab", 1000)).toBe(2);
18
+ });
19
+
20
+ test("antrean kecil: laju dasar (beberapa karakter per frame)", () => {
21
+ const text = "x".repeat(20);
22
+ const next = nextSmoothLength(0, text, FRAME);
23
+ expect(next).toBeGreaterThan(0);
24
+ expect(next).toBeLessThan(6);
25
+ });
26
+
27
+ test("antrean besar: laju naik agar tidak tertinggal", () => {
28
+ const text = "x".repeat(4000);
29
+ const small = nextSmoothLength(0, "x".repeat(20), FRAME);
30
+ const big = nextSmoothLength(0, text, FRAME);
31
+ expect(big).toBeGreaterThan(small * 5);
32
+ });
33
+
34
+ test("lonjakan besar: ~90% tampil dalam ~1 detik, tuntas dalam 3 detik", () => {
35
+ const text = "kata ".repeat(800); // 4000 karakter tiba sekaligus
36
+ let shown = 0;
37
+ let frames = 0;
38
+ let at90: number | null = null;
39
+ while (shown < text.length && frames < 1000) {
40
+ shown = nextSmoothLength(shown, text, FRAME);
41
+ frames++;
42
+ if (at90 === null && shown >= text.length * 0.9) at90 = frames * FRAME;
43
+ }
44
+ expect(shown).toBe(text.length);
45
+ expect(at90).not.toBeNull();
46
+ expect(at90 ?? Number.POSITIVE_INFINITY).toBeLessThan(1200);
47
+ expect(frames * FRAME).toBeLessThan(3000);
48
+ });
49
+
50
+ test("menempel ke batas kata bila dekat", () => {
51
+ const text = "halo dunia yang indah";
52
+ const next = nextSmoothLength(0, text, FRAME);
53
+ // Tidak berhenti di tengah kata "halo".
54
+ expect([" ", undefined]).toContain(text[next]);
55
+ });
56
+
57
+ test("lonjakan waktu (tab kembali aktif) dibatasi", () => {
58
+ const text = "x".repeat(20);
59
+ expect(nextSmoothLength(0, text, 10_000)).toBe(nextSmoothLength(0, text, 100));
60
+ });
61
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Unit test persistensi tema (task 22.2, Requirement 8.2 & 8.6).
3
+ *
4
+ * `bun test` tidak menyediakan DOM, sehingga hook diuji lewat fungsi murni
5
+ * (`resolveInitialTheme`, `applyTheme`, `nextTheme`, `getStoredTheme`) dengan
6
+ * mock `localStorage` (`ThemeStorageLike`) dan root `<html>` (`ThemeRootLike`)
7
+ * yang meniru urutan operasi yang dijalankan hook saat mount/toggle/remount.
8
+ */
9
+ import { expect, test } from "bun:test";
10
+ import {
11
+ applyTheme,
12
+ getStoredPreference,
13
+ getStoredTheme,
14
+ nextTheme,
15
+ resolveInitialTheme,
16
+ resolveTheme,
17
+ THEME_STORAGE_KEY,
18
+ type ThemeRootLike,
19
+ type ThemeStorageLike,
20
+ } from "../useTheme";
21
+
22
+ function makeStorage(initial: Record<string, string> = {}): ThemeStorageLike {
23
+ const map = new Map(Object.entries(initial));
24
+ return {
25
+ getItem: (key) => map.get(key) ?? null,
26
+ setItem: (key, value) => void map.set(key, value),
27
+ };
28
+ }
29
+
30
+ interface MockRoot extends ThemeRootLike {
31
+ dark: boolean;
32
+ }
33
+
34
+ function makeRoot(): MockRoot {
35
+ const root: MockRoot = {
36
+ dark: false,
37
+ classList: {
38
+ add: (name) => {
39
+ if (name === "dark") root.dark = true;
40
+ },
41
+ remove: (name) => {
42
+ if (name === "dark") root.dark = false;
43
+ },
44
+ toggle: (name, force) => {
45
+ if (name === "dark") root.dark = force ?? !root.dark;
46
+ },
47
+ },
48
+ };
49
+ return root;
50
+ }
51
+
52
+ test("22.2 (8.2): toggle mengubah tema saat ini + disimpan", () => {
53
+ const storage = makeStorage();
54
+ const root = makeRoot();
55
+
56
+ // Simulasi mount pertama: tanpa preferensi & sistem light -> light.
57
+ let theme = resolveInitialTheme(storage.getItem(THEME_STORAGE_KEY), () => false);
58
+ applyTheme(root, theme);
59
+ storage.setItem(THEME_STORAGE_KEY, theme);
60
+ expect(theme).toBe("light");
61
+ expect(root.dark).toBe(false);
62
+
63
+ // Toggle -> dark, class `dark` terpasang, preferensi tersimpan (8.2).
64
+ theme = nextTheme(theme);
65
+ applyTheme(root, theme);
66
+ storage.setItem(THEME_STORAGE_KEY, theme);
67
+ expect(theme).toBe("dark");
68
+ expect(root.dark).toBe(true);
69
+ expect(storage.getItem(THEME_STORAGE_KEY)).toBe("dark");
70
+
71
+ // Toggle lagi -> kembali light.
72
+ theme = nextTheme(theme);
73
+ applyTheme(root, theme);
74
+ expect(theme).toBe("light");
75
+ expect(root.dark).toBe(false);
76
+ });
77
+
78
+ test("22.2 (8.6): remount dengan preferensi tersimpan menerapkan dark otomatis", () => {
79
+ // Sesi sebelumnya mengaktifkan dark mode -> tersimpan di localStorage.
80
+ const storage = makeStorage({ [THEME_STORAGE_KEY]: "dark" });
81
+ const root = makeRoot();
82
+
83
+ // Simulasi mount ulang: tema awal dibaca dari preferensi tersimpan (8.6).
84
+ const theme = resolveInitialTheme(storage.getItem(THEME_STORAGE_KEY), () => false);
85
+ applyTheme(root, theme);
86
+ expect(theme).toBe("dark");
87
+ expect(root.dark).toBe(true);
88
+ });
89
+
90
+ test("22.2: tanpa preferensi -> mengikuti preferensi sistem", () => {
91
+ expect(resolveInitialTheme(null, () => true)).toBe("dark");
92
+ expect(resolveInitialTheme(null, () => false)).toBe("light");
93
+ // Preferensi tersimpan lebih diutamakan daripada preferensi sistem.
94
+ expect(resolveInitialTheme("light", () => true)).toBe("light");
95
+ expect(resolveInitialTheme("dark", () => false)).toBe("dark");
96
+ });
97
+
98
+ test("22.2: getStoredTheme hanya mengenali nilai valid", () => {
99
+ expect(getStoredTheme(makeStorage({ [THEME_STORAGE_KEY]: "dark" }))).toBe("dark");
100
+ expect(getStoredTheme(makeStorage({ [THEME_STORAGE_KEY]: "light" }))).toBe("light");
101
+ expect(getStoredTheme(makeStorage({ [THEME_STORAGE_KEY]: "bogus" }))).toBeNull();
102
+ expect(getStoredTheme(makeStorage())).toBeNull();
103
+ });
104
+
105
+ test("preferensi 'system' mengikuti sistem; 'light'/'dark' tetap", () => {
106
+ expect(resolveTheme("system", () => true)).toBe("dark");
107
+ expect(resolveTheme("system", () => false)).toBe("light");
108
+ expect(resolveTheme("light", () => true)).toBe("light");
109
+ expect(resolveTheme("dark", () => false)).toBe("dark");
110
+ });
111
+
112
+ test("getStoredPreference: nilai valid; kosong/tak dikenal -> system", () => {
113
+ expect(getStoredPreference(makeStorage({ [THEME_STORAGE_KEY]: "dark" }))).toBe("dark");
114
+ expect(getStoredPreference(makeStorage({ [THEME_STORAGE_KEY]: "light" }))).toBe("light");
115
+ expect(getStoredPreference(makeStorage({ [THEME_STORAGE_KEY]: "system" }))).toBe("system");
116
+ expect(getStoredPreference(makeStorage({ [THEME_STORAGE_KEY]: "bogus" }))).toBe("system");
117
+ expect(getStoredPreference(makeStorage())).toBe("system");
118
+ });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Deteksi viewport di bawah breakpoint `md` (768px) untuk komponen shadcn
3
+ * `sidebar` — di bawah ambang ini sidebar dirender sebagai Sheet (off-canvas).
4
+ *
5
+ * Memakai `useMediaQuery` agar render pertama langsung benar (tanpa flash
6
+ * sidebar desktop di HP). Ambang sengaja `md`, bukan `sm` seperti
7
+ * `useIsMobile` di `useMediaQuery.ts`, karena kelas responsif di
8
+ * `components/ui/sidebar.tsx` memakai varian `md:`.
9
+ */
10
+ import { useMediaQuery } from "@/hooks/useMediaQuery";
11
+
12
+ const MOBILE_BREAKPOINT = 768;
13
+
14
+ export function useIsMobile(): boolean {
15
+ return useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
16
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Logika bersama pemilih agent (mode) opencode: memuat daftar agent project
3
+ * (lazy, sekali per komponen) + mengirim perubahan agent Session.
4
+ *
5
+ * Dipakai oleh `AgentPicker` (trigger + popover di desktop) dan langsung oleh
6
+ * `SessionView` untuk daftar mode di Sheet aksi mobile — supaya tap satu mode
7
+ * di Sheet tidak perlu buka popover lagi di dalam popover.
8
+ */
9
+ import { useCallback, useRef, useState } from "react";
10
+ import { ApiError, apiFetch } from "@/lib/api";
11
+ import type { AgentOption } from "@/server/services/opencode-client";
12
+
13
+ export interface UseAgentPickerResult {
14
+ agents: AgentOption[] | null;
15
+ loading: boolean;
16
+ saving: boolean;
17
+ error: string | null;
18
+ /** Muat daftar agent project (no-op bila sudah dimuat / sedang memuat). */
19
+ load: () => void;
20
+ /** Ganti agent Session; `null` = kembali ke default opencode. */
21
+ pick: (name: string | null) => Promise<void>;
22
+ }
23
+
24
+ export function useAgentPicker(
25
+ projectId: string,
26
+ /** null = Session belum ada (composer homepage): pilihan tidak di-PUT. */
27
+ sessionId: string | null,
28
+ onChanged: (agent: string | null) => void,
29
+ ): UseAgentPickerResult {
30
+ const [agents, setAgents] = useState<AgentOption[] | null>(null);
31
+ const [loading, setLoading] = useState(false);
32
+ const [saving, setSaving] = useState(false);
33
+ const [error, setError] = useState<string | null>(null);
34
+ /**
35
+ * Penanda fetch sedang berjalan — ref, BUKAN state `loading`. Bila `load`
36
+ * bergantung pada `loading`, identitasnya berganti tiap fetch selesai, dan
37
+ * efek pemanggil (`if (open) load()`) jalan lagi -> fetch tanpa henti
38
+ * (spinner berkedip terus).
39
+ */
40
+ const inFlight = useRef(false);
41
+ const saveInFlight = useRef(false);
42
+ /** Callback terbaru via ref — pemanggil sering mengoper arrow function inline. */
43
+ const onChangedRef = useRef(onChanged);
44
+ onChangedRef.current = onChanged;
45
+
46
+ /**
47
+ * Muat daftar agent project. Selalu fetch ulang (no-op bila sedang memuat)
48
+ * — config opencode bisa berubah dan server headless tidak hot-reload.
49
+ * Identitas stabil per `projectId`.
50
+ */
51
+ const load = useCallback(() => {
52
+ if (inFlight.current) return;
53
+ inFlight.current = true;
54
+ setLoading(true);
55
+ setError(null);
56
+ void (async () => {
57
+ try {
58
+ const res = await apiFetch(`/api/projects/${projectId}/agents`);
59
+ const body = (await res.json()) as { agents: AgentOption[] };
60
+ setAgents(body.agents);
61
+ } catch (e) {
62
+ setError(e instanceof ApiError ? e.message : "Failed to load agent list");
63
+ setAgents([]);
64
+ } finally {
65
+ inFlight.current = false;
66
+ setLoading(false);
67
+ }
68
+ })();
69
+ }, [projectId]);
70
+
71
+ const pick = useCallback(
72
+ async (name: string | null) => {
73
+ if (saveInFlight.current) return;
74
+ saveInFlight.current = true;
75
+ setSaving(true);
76
+ setError(null);
77
+ try {
78
+ if (sessionId !== null) {
79
+ await apiFetch(`/api/sessions/${sessionId}`, {
80
+ method: "PUT",
81
+ headers: { "content-type": "application/json" },
82
+ body: JSON.stringify({ agent: name }),
83
+ });
84
+ }
85
+ onChangedRef.current(name);
86
+ } catch (e) {
87
+ setError(e instanceof ApiError ? e.message : "Failed to change agent");
88
+ } finally {
89
+ saveInFlight.current = false;
90
+ setSaving(false);
91
+ }
92
+ },
93
+ [sessionId],
94
+ );
95
+
96
+ return { agents, loading, saving, error, load, pick };
97
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Deteksi viewport sempit (layar HP) untuk penyesuaian UI yang tidak bisa
3
+ * dilakukan lewat CSS saja — mis. memilih teks placeholder yang lebih pendek
4
+ * atau merender picker sebagai dialog penuh, bukan dropdown.
5
+ *
6
+ * Untuk perbedaan yang murni tampilan, tetap pakai varian Tailwind (`sm:`);
7
+ * hook ini hanya untuk keputusan di level JavaScript.
8
+ *
9
+ * Ambang 640px mengikuti breakpoint `sm` Tailwind agar konsisten dengan
10
+ * kelas responsif di komponen.
11
+ */
12
+
13
+ import { useEffect, useState } from "react";
14
+
15
+ /** Ambang lebar viewport "mobile" (px) — sinkron dengan breakpoint `sm`. */
16
+ export const MOBILE_MAX_WIDTH = 639;
17
+
18
+ /** Media query default: viewport lebih sempit dari breakpoint `sm`. */
19
+ export const MOBILE_QUERY = `(max-width: ${MOBILE_MAX_WIDTH}px)`;
20
+
21
+ /**
22
+ * `true` selama viewport cocok dengan `query`. Server-side / lingkungan tanpa
23
+ * `matchMedia` mengembalikan `false` (asumsi layar lebar) sehingga render
24
+ * pertama tidak pernah gagal.
25
+ */
26
+ export function useMediaQuery(query: string): boolean {
27
+ const [matches, setMatches] = useState(() => {
28
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
29
+ return window.matchMedia(query).matches;
30
+ });
31
+
32
+ useEffect(() => {
33
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
34
+ const mql = window.matchMedia(query);
35
+ // Sinkronkan sekali: `query` bisa berubah, atau viewport sudah bergeser
36
+ // antara render pertama dan efek ini berjalan.
37
+ setMatches(mql.matches);
38
+ const onChange = (e: MediaQueryListEvent) => setMatches(e.matches);
39
+ mql.addEventListener("change", onChange);
40
+ return () => mql.removeEventListener("change", onChange);
41
+ }, [query]);
42
+
43
+ return matches;
44
+ }
45
+
46
+ /** Pintasan: viewport selebar HP (di bawah breakpoint `sm`). */
47
+ export function useIsMobile(): boolean {
48
+ return useMediaQuery(MOBILE_QUERY);
49
+ }