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,234 @@
1
+ /**
2
+ * Engine halaman daftar Session milik satu Project (`SessionList.tsx`).
3
+ *
4
+ * Mengelola pemuatan daftar (`GET /api/sessions` di-filter per Project),
5
+ * aksi per-Session (stop/start/hapus), serta dialog konfirmasi hapus Project
6
+ * (yang menghapus seluruh Session-nya). Sisa logika form pembuatan Session
7
+ * baru hidup di `NewSessionDialog`.
8
+ */
9
+ import { useCallback, useEffect, useMemo, useState } from "react";
10
+ import { toast } from "sonner";
11
+ import { useResyncOnReconnect } from "@/hooks/useResyncOnReconnect";
12
+ import { useWebSocket } from "@/hooks/useWebSocket";
13
+ import { ApiError, apiFetch } from "@/lib/api";
14
+ import { notifyDataChanged } from "@/lib/data-events";
15
+ import { type SessionSummary, sortSessions, summarizeSessions } from "@/lib/session-summary";
16
+ import type { Session } from "@/types";
17
+
18
+ export interface UseSessionListOptions {
19
+ projectId: string;
20
+ /** Dipanggil setelah Project dihapus (kembali ke daftar Project). */
21
+ onDeleted?: () => void;
22
+ }
23
+
24
+ export interface SessionListEngine {
25
+ sessions: Session[];
26
+ loading: boolean;
27
+ loadError: string | null;
28
+ /** Session terurut: running -> crashed -> stopped, terbaru di atas. */
29
+ ordered: Session[];
30
+ summary: SessionSummary;
31
+ refresh: (opts?: { silent?: boolean }) => Promise<void>;
32
+
33
+ // Aksi per-Session
34
+ stopping: string | null;
35
+ stop: (sessionId: string) => Promise<void>;
36
+ starting: string | null;
37
+ start: (sessionId: string) => Promise<void>;
38
+ /** Session yang sedang dihapus (id), untuk spinner di baris. */
39
+ deleting: string | null;
40
+ pendingDelete: Session | null;
41
+ requestDelete: (session: Session) => void;
42
+ cancelDelete: () => void;
43
+ confirmRemove: () => Promise<void>;
44
+
45
+ // Hapus Project (beserta seluruh Session-nya)
46
+ projectDeleteOpen: boolean;
47
+ openProjectDelete: () => void;
48
+ closeProjectDelete: () => void;
49
+ deletingProject: boolean;
50
+ confirmRemoveProject: () => Promise<void>;
51
+ }
52
+
53
+ export function useSessionList({ projectId, onDeleted }: UseSessionListOptions): SessionListEngine {
54
+ const [sessions, setSessions] = useState<Session[]>([]);
55
+ const [loading, setLoading] = useState(true);
56
+ const [loadError, setLoadError] = useState<string | null>(null);
57
+ const [stopping, setStopping] = useState<string | null>(null);
58
+ const [starting, setStarting] = useState<string | null>(null);
59
+ const [deleting, setDeleting] = useState<string | null>(null);
60
+ /** Session yang akan dihapus, dikonfirmasi lewat `AlertDialog`. */
61
+ const [pendingDelete, setPendingDelete] = useState<Session | null>(null);
62
+ /** Konfirmasi hapus Project (beserta seluruh Session-nya). */
63
+ const [projectDeleteOpen, setProjectDeleteOpen] = useState(false);
64
+ const [deletingProject, setDeletingProject] = useState(false);
65
+
66
+ /** `silent`: muat ulang di latar (tanpa skeleton) — sinkron dari server. */
67
+ const refresh = useCallback(
68
+ async ({ silent = false }: { silent?: boolean } = {}) => {
69
+ if (!silent) setLoading(true);
70
+ setLoadError(null);
71
+ try {
72
+ const res = await apiFetch("/api/sessions");
73
+ const body = (await res.json()) as { sessions: Session[] };
74
+ setSessions(body.sessions.filter((s) => s.projectId === projectId));
75
+ } catch (e) {
76
+ setLoadError(e instanceof ApiError ? e.message : "Failed to load sessions");
77
+ } finally {
78
+ setLoading(false);
79
+ }
80
+ },
81
+ [projectId],
82
+ );
83
+
84
+ useEffect(() => {
85
+ void refresh();
86
+ }, [refresh]);
87
+
88
+ /**
89
+ * Judul Session dibuat otomatis oleh opencode SETELAH prompt pertama —
90
+ * daftar yang dimuat saat halaman dibuka belum memuatnya. Satu koneksi WS
91
+ * mode daftar (`attach("")`, tanpa history) menerima broadcast
92
+ * `session_title`, `session_status`, dan `session_deleted` agar baris
93
+ * terkait terbarui live tanpa refresh manual.
94
+ */
95
+ const ws = useWebSocket({
96
+ onMessage: (msg) => {
97
+ if (msg.type === "session_title") {
98
+ // Update baris terkait saja — state lain (urutan, summary) tidak berubah.
99
+ setSessions((prev) =>
100
+ prev.map((s) => (s.id === msg.sessionId ? { ...s, title: msg.title } : s)),
101
+ );
102
+ } else if (msg.type === "session_status") {
103
+ // Status berubah di tempat lain (Session view, crash, perangkat lain).
104
+ setSessions((prev) =>
105
+ prev.map((s) =>
106
+ s.id === msg.sessionId && s.status !== msg.status ? { ...s, status: msg.status } : s,
107
+ ),
108
+ );
109
+ } else if (msg.type === "session_deleted") {
110
+ setSessions((prev) => prev.filter((s) => s.id !== msg.sessionId));
111
+ } else if (msg.type === "data_changed") {
112
+ // Session baru dibuat di perangkat/tab lain -> muat ulang tanpa skeleton.
113
+ void refresh({ silent: true });
114
+ }
115
+ },
116
+ });
117
+ useEffect(() => {
118
+ ws.attach("");
119
+ return () => ws.disconnect();
120
+ }, [ws.attach, ws.disconnect]);
121
+ const resync = useCallback(() => refresh({ silent: true }), [refresh]);
122
+ useResyncOnReconnect(ws.status, resync);
123
+
124
+ // Running -> crashed -> stopped, terbaru di atas (lib/session-summary.ts).
125
+ const ordered = useMemo(() => sortSessions(sessions), [sessions]);
126
+ const summary = useMemo(() => summarizeSessions(sessions), [sessions]);
127
+
128
+ /** Stop ≠ hapus: POST /stop hanya mengubah status; data tetap ada dan
129
+ bisa dihidupkan kembali lewat aksi Hidupkan (resume). */
130
+ const stop = useCallback(
131
+ async (sessionId: string) => {
132
+ setStopping(sessionId);
133
+ try {
134
+ await apiFetch(`/api/sessions/${sessionId}/stop`, { method: "POST" });
135
+ toast.success("Session stopped.");
136
+ await refresh();
137
+ notifyDataChanged();
138
+ } catch (e) {
139
+ toast.error(e instanceof ApiError ? e.message : "Couldn't stop the session.");
140
+ } finally {
141
+ setStopping(null);
142
+ }
143
+ },
144
+ [refresh],
145
+ );
146
+
147
+ /** Resume Session stopped/crashed: POST /api/sessions/:id. */
148
+ const start = useCallback(
149
+ async (sessionId: string) => {
150
+ setStarting(sessionId);
151
+ try {
152
+ await apiFetch(`/api/sessions/${sessionId}`, { method: "POST" });
153
+ toast.success("Session resumed.");
154
+ await refresh();
155
+ notifyDataChanged();
156
+ } catch (e) {
157
+ toast.error(e instanceof ApiError ? e.message : "Couldn't resume the session.");
158
+ } finally {
159
+ setStarting(null);
160
+ }
161
+ },
162
+ [refresh],
163
+ );
164
+
165
+ const requestDelete = useCallback((session: Session) => setPendingDelete(session), []);
166
+ const cancelDelete = useCallback(() => setPendingDelete(null), []);
167
+
168
+ /**
169
+ * Hapus Session permanen — menghapus juga session (dan riwayat pesannya)
170
+ * di server headless opencode. Dikonfirmasi via `AlertDialog` sebelum
171
+ * eksekusi karena tidak bisa dibatalkan.
172
+ */
173
+ const confirmRemove = useCallback(async () => {
174
+ const session = pendingDelete;
175
+ if (!session) return;
176
+ setDeleting(session.id);
177
+ try {
178
+ await apiFetch(`/api/sessions/${session.id}`, { method: "DELETE" });
179
+ setPendingDelete(null);
180
+ toast.success("Session deleted.");
181
+ await refresh();
182
+ notifyDataChanged();
183
+ } catch (e) {
184
+ toast.error(e instanceof ApiError ? e.message : "Couldn't delete the session.");
185
+ } finally {
186
+ setDeleting(null);
187
+ }
188
+ }, [pendingDelete, refresh]);
189
+
190
+ const openProjectDelete = useCallback(() => setProjectDeleteOpen(true), []);
191
+ const closeProjectDelete = useCallback(() => setProjectDeleteOpen(false), []);
192
+
193
+ /**
194
+ * Hapus pendaftaran Project. Server membersihkan seluruh Session (termasuk
195
+ * sesi remote opencode) lebih dulu; direktori kerja di filesystem tetap ada.
196
+ */
197
+ const confirmRemoveProject = useCallback(async () => {
198
+ setDeletingProject(true);
199
+ try {
200
+ await apiFetch(`/api/projects/${projectId}`, { method: "DELETE" });
201
+ setProjectDeleteOpen(false);
202
+ toast.success("Project removed.");
203
+ notifyDataChanged();
204
+ onDeleted?.();
205
+ } catch (e) {
206
+ toast.error(e instanceof ApiError ? e.message : "Couldn't remove the project.");
207
+ } finally {
208
+ setDeletingProject(false);
209
+ }
210
+ }, [onDeleted, projectId]);
211
+
212
+ return {
213
+ sessions,
214
+ loading,
215
+ loadError,
216
+ ordered,
217
+ summary,
218
+ refresh,
219
+ stopping,
220
+ stop,
221
+ starting,
222
+ start,
223
+ deleting,
224
+ pendingDelete,
225
+ requestDelete,
226
+ cancelDelete,
227
+ confirmRemove,
228
+ projectDeleteOpen,
229
+ openProjectDelete,
230
+ closeProjectDelete,
231
+ deletingProject,
232
+ confirmRemoveProject,
233
+ };
234
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Data sidebar aplikasi: seluruh Project + Session, digroup per Project.
3
+ *
4
+ * - `GET /api/projects` + `GET /api/sessions` dimuat bersamaan, lalu
5
+ * dikelompokkan `lib/sidebar-groups.ts` (murni, teruji).
6
+ * - Dimuat ulang setiap `pathname` berubah, sehingga Session/Project yang
7
+ * baru dibuat atau dihapus ikut terlihat setelah navigasi.
8
+ * - Diperbarui live lewat WS mode daftar (`attach("")`) yang menerima
9
+ * broadcast global: `session_title` (judul), `session_status` (titik
10
+ * running/stopped/crashed), dan `session_deleted` (baris hilang).
11
+ * - Muat ulang penuh saat: `data_changed` dari server (Project/Session
12
+ * dibuat/dihapus di perangkat mana pun), `notifyDataChanged()` lokal, dan
13
+ * koneksi WS tersambung ulang (kabar yang terlewat selama putus).
14
+ */
15
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
16
+ import { useResyncOnReconnect } from "@/hooks/useResyncOnReconnect";
17
+ import { useWebSocket } from "@/hooks/useWebSocket";
18
+ import { ApiError, apiFetch } from "@/lib/api";
19
+ import { onDataChanged } from "@/lib/data-events";
20
+ import { groupSessionsByProject, type SidebarProjectGroup } from "@/lib/sidebar-groups";
21
+ import type { Project, Session } from "@/types";
22
+
23
+ export interface UseSidebarDataResult {
24
+ /** Seluruh grup Project + Session (pencarian ada di `SearchDialog`). */
25
+ groups: SidebarProjectGroup[];
26
+ /** Total Project sebelum filter — membedakan "kosong" vs "tidak cocok". */
27
+ projectCount: number;
28
+ /** `true` hanya pada pemuatan pertama (refresh berikutnya tanpa skeleton). */
29
+ initialLoading: boolean;
30
+ loadError: string | null;
31
+ refresh: () => Promise<void>;
32
+ }
33
+
34
+ export function useSidebarData(pathname: string): UseSidebarDataResult {
35
+ const [projects, setProjects] = useState<Project[]>([]);
36
+ const [sessions, setSessions] = useState<Session[]>([]);
37
+ const [loaded, setLoaded] = useState(false);
38
+ const [loadError, setLoadError] = useState<string | null>(null);
39
+ /** Abaikan respon lama bila refresh baru sudah dimulai (navigasi cepat). */
40
+ const requestSeq = useRef(0);
41
+
42
+ const refresh = useCallback(async () => {
43
+ const seq = ++requestSeq.current;
44
+ try {
45
+ const [projectsRes, sessionsRes] = await Promise.all([
46
+ apiFetch("/api/projects"),
47
+ apiFetch("/api/sessions"),
48
+ ]);
49
+ const [p, s] = (await Promise.all([projectsRes.json(), sessionsRes.json()])) as [
50
+ { projects: Project[] },
51
+ { sessions: Session[] },
52
+ ];
53
+ if (seq !== requestSeq.current) return;
54
+ setProjects(p.projects);
55
+ setSessions(s.sessions);
56
+ setLoadError(null);
57
+ } catch (e) {
58
+ if (seq !== requestSeq.current) return;
59
+ setLoadError(e instanceof ApiError ? e.message : "Failed to load sessions");
60
+ } finally {
61
+ if (seq === requestSeq.current) setLoaded(true);
62
+ }
63
+ }, []);
64
+
65
+ // biome-ignore lint/correctness/useExhaustiveDependencies: pathname sengaja jadi trigger refresh
66
+ useEffect(() => {
67
+ void refresh();
68
+ }, [refresh, pathname]);
69
+
70
+ // Perubahan data dari halaman lain (mis. Project baru di homepage).
71
+ useEffect(() => onDataChanged(() => void refresh()), [refresh]);
72
+
73
+ const ws = useWebSocket({
74
+ onMessage: (msg) => {
75
+ if (msg.type === "session_title") {
76
+ setSessions((prev) =>
77
+ prev.map((s) => (s.id === msg.sessionId ? { ...s, title: msg.title } : s)),
78
+ );
79
+ } else if (msg.type === "session_status") {
80
+ setSessions((prev) =>
81
+ prev.map((s) =>
82
+ s.id === msg.sessionId && s.status !== msg.status ? { ...s, status: msg.status } : s,
83
+ ),
84
+ );
85
+ } else if (msg.type === "session_deleted") {
86
+ setSessions((prev) => prev.filter((s) => s.id !== msg.sessionId));
87
+ } else if (msg.type === "data_changed") {
88
+ // Project/Session dibuat atau dihapus (bisa dari perangkat lain).
89
+ void refresh();
90
+ }
91
+ },
92
+ });
93
+ useEffect(() => {
94
+ ws.attach("");
95
+ return () => ws.disconnect();
96
+ }, [ws.attach, ws.disconnect]);
97
+ useResyncOnReconnect(ws.status, refresh);
98
+
99
+ const groups = useMemo(() => groupSessionsByProject(projects, sessions), [projects, sessions]);
100
+
101
+ return {
102
+ groups,
103
+ projectCount: projects.length,
104
+ initialLoading: !loaded,
105
+ loadError,
106
+ refresh,
107
+ };
108
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `useSmoothText` — haluskan teks yang tiba bertahap (streaming token model).
3
+ *
4
+ * Masalah: delta dari opencode datang tidak merata — kadang 2 huruf, kadang
5
+ * satu frasa sekaligus, dengan jeda acak. Menampilkannya apa adanya terasa
6
+ * patah-patah (teks "melompat" per potongan).
7
+ *
8
+ * Solusi: teks target ditampung, lalu yang TAMPIL dikejar per frame
9
+ * (`requestAnimationFrame`) dengan laju adaptif:
10
+ * - Laju dasar ~`BASE_CPS` karakter/detik agar ritme terasa seperti mengetik.
11
+ * - Bila antrean menumpuk, laju naik sebanding sisa antrean: antrean
12
+ * menyusut eksponensial dengan konstanta waktu ~`CATCH_UP_MS` (sekitar 90%
13
+ * lonjakan sudah tampil dalam ~1 detik), lalu ekornya pada laju dasar.
14
+ * Begitu streaming selesai, sisa teks langsung tampil penuh.
15
+ * - Potongan dibulatkan ke batas kata bila dekat, agar tidak memotong kata
16
+ * di tengah terlalu sering (lebih enak dibaca).
17
+ *
18
+ * Aturan lain:
19
+ * - `enabled=false` (pesan selesai / riwayat): tampilkan teks penuh seketika.
20
+ * - Teks target yang bukan kelanjutan dari yang tampil (mis. diganti
21
+ * snapshot berbeda) -> langsung disetel ke target, tanpa animasi mundur.
22
+ * - Reduced motion: tanpa animasi (teks penuh seketika).
23
+ * - Tab di latar belakang: rAF berhenti; saat kembali, sisa antrean dikejar
24
+ * cepat oleh laju adaptif.
25
+ */
26
+ import { useCallback, useEffect, useRef, useState } from "react";
27
+
28
+ /** Laju dasar (karakter per detik) saat antrean kecil. */
29
+ const BASE_CPS = 90;
30
+ /** Konstanta waktu penyusutan antrean (ms) — makin kecil, makin cepat mengejar. */
31
+ const CATCH_UP_MS = 400;
32
+ /** Jarak maksimum (karakter) untuk "menempel" ke batas kata berikutnya. */
33
+ const WORD_SNAP = 12;
34
+
35
+ function prefersReducedMotion(): boolean {
36
+ return (
37
+ typeof window !== "undefined" &&
38
+ typeof window.matchMedia === "function" &&
39
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Hitung panjang tampilan berikutnya (murni, diuji terpisah).
45
+ * @param shown panjang yang sedang tampil
46
+ * @param target teks target penuh
47
+ * @param dtMs selisih waktu sejak frame sebelumnya
48
+ */
49
+ export function nextSmoothLength(shown: number, target: string, dtMs: number): number {
50
+ const remaining = target.length - shown;
51
+ if (remaining <= 0) return target.length;
52
+ const dt = Math.max(0, Math.min(dtMs, 100)); // batasi lonjakan (tab kembali aktif)
53
+ const adaptiveCps = (remaining / CATCH_UP_MS) * 1000;
54
+ const cps = Math.max(BASE_CPS, adaptiveCps);
55
+ let next = shown + Math.max(1, Math.round((cps * dt) / 1000));
56
+ if (next >= target.length) return target.length;
57
+ // Tempel ke akhir kata terdekat bila dekat — hindari memotong kata.
58
+ const rest = target.slice(next, next + WORD_SNAP);
59
+ const boundary = rest.search(/\s/);
60
+ if (boundary > 0) next += boundary;
61
+ return Math.min(next, target.length);
62
+ }
63
+
64
+ export function useSmoothText(target: string, enabled: boolean): string {
65
+ const [shown, setShown] = useState(() => (enabled ? "" : target));
66
+ const shownRef = useRef(shown);
67
+ // Target terbaru dibaca loop tiap frame — tidak perlu memasang ulang loop
68
+ // setiap delta tiba.
69
+ const targetRef = useRef(target);
70
+ targetRef.current = target;
71
+
72
+ const commit = useCallback((value: string) => {
73
+ if (shownRef.current === value) return;
74
+ shownRef.current = value;
75
+ setShown(value);
76
+ }, []);
77
+
78
+ /**
79
+ * Satu loop rAF per masa streaming (`enabled`), dimiliki SATU effect dengan
80
+ * cleanup yang membatalkannya. Sebelumnya loop dipicu per perubahan target
81
+ * dan dijaga flag `frameRef`; di StrictMode (effect dijalankan
82
+ * pasang -> bongkar -> pasang) flag itu tertinggal non-null setelah rAF
83
+ * dibatalkan, sehingga loop tak pernah jalan lagi dan teks baru muncul
84
+ * sekaligus saat streaming selesai.
85
+ *
86
+ * Loop berjalan selama `enabled` (hanya blok terakhir yang sedang
87
+ * di-stream); saat sudah mengejar target, frame hanya membandingkan
88
+ * panjang tanpa setState — murah.
89
+ */
90
+ useEffect(() => {
91
+ if (!enabled || prefersReducedMotion()) return;
92
+ // Selaraskan state dengan ref (ref bisa sudah maju saat nonaktif).
93
+ setShown(shownRef.current);
94
+ let frame = 0;
95
+ let last: number | null = null;
96
+ const tick = (ts: number) => {
97
+ const dt = last === null ? 16 : ts - last;
98
+ last = ts;
99
+ const goal = targetRef.current;
100
+ const cur = shownRef.current;
101
+ if (!goal.startsWith(cur)) {
102
+ // Bukan kelanjutan (teks diganti snapshot lain) -> setel langsung,
103
+ // jangan animasi "mundur".
104
+ commit(goal);
105
+ } else if (cur.length < goal.length) {
106
+ commit(goal.slice(0, nextSmoothLength(cur.length, goal, dt)));
107
+ }
108
+ frame = requestAnimationFrame(tick);
109
+ };
110
+ frame = requestAnimationFrame(tick);
111
+ return () => cancelAnimationFrame(frame);
112
+ }, [enabled, commit]);
113
+
114
+ // Tidak streaming / reduced motion: teks penuh apa adanya. `shownRef`
115
+ // ikut disinkronkan agar bila streaming berlanjut, animasi tidak mengulang
116
+ // dari awal.
117
+ if (!enabled || prefersReducedMotion()) {
118
+ shownRef.current = target;
119
+ return target;
120
+ }
121
+ return shown;
122
+ }
@@ -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
+ }