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,122 @@
1
+ /**
2
+ * Halaman Session view (`/projects/:projectId/sessions/:sessionId`).
3
+ *
4
+ * - `Session` di-resolve dari id di URL via `GET /api/sessions` (Requirement
5
+ * 1.5) sehingga halaman dapat di-refresh / diakses langsung (direct link)
6
+ * tanpa state dari halaman sebelumnya.
7
+ * - Session tidak ditemukan (sudah dihapus / id salah) -> pesan error dengan
8
+ * aksi kembali ke daftar Session Project terkait.
9
+ * - Render memakai `SessionView` yang sudah ada; dibungkus container yang
10
+ * sama dengan halaman lain.
11
+ */
12
+
13
+ import { useCallback, useEffect, useState } from "react";
14
+ import {
15
+ SessionComposerSkeleton,
16
+ SessionTimelineSkeleton,
17
+ } from "@/components/sessions/SessionTimelineSkeleton";
18
+ import { SessionView } from "@/components/sessions/SessionView";
19
+ import { Skeleton } from "@/components/ui/skeleton";
20
+ import { useRouter } from "@/hooks/useRouter";
21
+ import { ApiError, apiErrorMessage, apiFetch } from "@/lib/api";
22
+ import { projectPath } from "@/lib/routes";
23
+ import { clearSeededSession, peekSeededSession } from "@/lib/session-handoff";
24
+ import type { Session } from "@/types";
25
+
26
+ export interface SessionPageProps {
27
+ projectId: string;
28
+ sessionId: string;
29
+ }
30
+
31
+ type LoadState =
32
+ | { phase: "loading" }
33
+ | { phase: "error"; code: string }
34
+ | { phase: "ready"; session: Session };
35
+
36
+ export function SessionPage({ projectId, sessionId }: SessionPageProps) {
37
+ const { navigate } = useRouter();
38
+ // Baru dibuat dari homepage? Render langsung dari data titipan (tanpa
39
+ // "Loading…") agar View Transition punya halaman tujuan yang utuh.
40
+ const [state, setState] = useState<LoadState>(() => {
41
+ const seeded = peekSeededSession(sessionId);
42
+ return seeded && seeded.projectId === projectId
43
+ ? { phase: "ready", session: seeded }
44
+ : { phase: "loading" };
45
+ });
46
+
47
+ const load = useCallback(async () => {
48
+ if (peekSeededSession(sessionId)?.projectId === projectId) {
49
+ clearSeededSession(sessionId);
50
+ return;
51
+ }
52
+ setState({ phase: "loading" });
53
+ try {
54
+ const res = await apiFetch("/api/sessions");
55
+ const body = (await res.json()) as { sessions: Session[] };
56
+ const session = body.sessions.find((s) => s.id === sessionId && s.projectId === projectId);
57
+ if (session === undefined) {
58
+ setState({ phase: "error", code: "SESSION_NOT_FOUND" });
59
+ } else {
60
+ setState({ phase: "ready", session });
61
+ }
62
+ } catch (e) {
63
+ setState({
64
+ phase: "error",
65
+ code: e instanceof ApiError ? e.code : "INTERNAL_ERROR",
66
+ });
67
+ }
68
+ }, [projectId, sessionId]);
69
+
70
+ useEffect(() => {
71
+ void load();
72
+ }, [load]);
73
+
74
+ if (state.phase === "loading") {
75
+ // Kerangka setara SessionView (header + timeline + composer) — bagian
76
+ // timeline & composer SAMA dengan yang dipakai SessionView saat menunggu
77
+ // riwayat, jadi peralihan dua fase loading tidak berkedip.
78
+ return (
79
+ <div className="flex h-full min-h-0 flex-col" aria-busy="true">
80
+ <div className="flex shrink-0 items-center gap-2 px-3 py-3 sm:px-4">
81
+ <Skeleton className="size-9 rounded-md" />
82
+ <div className="flex flex-col gap-1.5">
83
+ <Skeleton className="h-4 w-40" />
84
+ <Skeleton className="hidden h-3 w-56 sm:block" />
85
+ </div>
86
+ </div>
87
+ <SessionTimelineSkeleton />
88
+ <SessionComposerSkeleton />
89
+ </div>
90
+ );
91
+ }
92
+
93
+ if (state.phase === "error") {
94
+ return (
95
+ <div className="flex flex-col items-center gap-4 py-10 text-center">
96
+ <p className="text-sm text-muted-foreground">{apiErrorMessage(state.code)}</p>
97
+ <a
98
+ href={projectPath(projectId)}
99
+ onClick={(e) => {
100
+ e.preventDefault();
101
+ navigate(projectPath(projectId));
102
+ }}
103
+ className="text-sm font-medium underline underline-offset-4"
104
+ >
105
+ Back to sessions
106
+ </a>
107
+ </div>
108
+ );
109
+ }
110
+
111
+ return (
112
+ <main className="min-h-0 flex-1">
113
+ <SessionView
114
+ key={state.session.id}
115
+ session={state.session}
116
+ onBack={() => navigate(projectPath(projectId))}
117
+ // Session sudah tidak ada — kembali ke daftar Session Project.
118
+ onDeleted={() => navigate(projectPath(projectId))}
119
+ />
120
+ </main>
121
+ );
122
+ }
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Halaman Settings (`/settings/:section`) — navigasi ala GNOME Settings
3
+ * (AdwNavigationSplitView, libadwaita). Isi tiap bagian memakai "boxed list"
4
+ * (`components/settings/prefs`). Tiap bagian punya URL sendiri:
5
+ *
6
+ * - Profile (`/settings`, `/settings/profile`): avatar, nickname, akun.
7
+ * - Security (`/settings/security`): kunci aplikasi, auto-lock, passkey, dan
8
+ * perangkat yang login (`/settings/devices` lama -> grup ini).
9
+ * - Workspace (`/settings/workspace`): folder tempat semua Project berada.
10
+ * - Remote access (`/settings/remote`): tunnel publik lewat Google sign-in.
11
+ * - About KCG Code (`/settings/about`): versi & kredit — bukan pengaturan,
12
+ * jadi tampil terpisah di akhir navigasi (seperti "About" GNOME Settings).
13
+ *
14
+ * Tata letak:
15
+ * - Lebar (lg+): daftar navigasi di kiri (sticky), isi bagian di kanan.
16
+ * - Sempit: `/settings` = daftar bagian (baris dengan ikon, deskripsi,
17
+ * chevron); bagian dibuka sebagai halaman dengan tombol kembali
18
+ * "‹ Settings" + judul di tengah (AdwNavigationView).
19
+ */
20
+ import { ChevronLeftIcon, FolderIcon, GlobeIcon, ShieldIcon, UserIcon } from "lucide-react";
21
+ import { type ComponentType, type ReactNode, useCallback, useEffect } from "react";
22
+ import { toast } from "sonner";
23
+ import { AvatarEditor } from "@/components/auth/AvatarEditor";
24
+ import { ProfileAvatar } from "@/components/auth/ProfileAvatar";
25
+ import { AboutGroup } from "@/components/settings/AboutGroup";
26
+ import { AccountGroup } from "@/components/settings/AccountGroup";
27
+ import { ActionRow, EntryRow, PrefsGroup } from "@/components/settings/prefs";
28
+ import { RemoteAccessGroup } from "@/components/settings/RemoteAccessGroup";
29
+ import { SecurityGroup } from "@/components/settings/SecurityGroup";
30
+ import { WorkspaceGroup } from "@/components/settings/WorkspaceGroup";
31
+ import { Spinner } from "@/components/ui/spinner";
32
+ import { useMediaQuery } from "@/hooks/useMediaQuery";
33
+ import { useRouter } from "@/hooks/useRouter";
34
+ import { ApiError, apiFetch } from "@/lib/api";
35
+ import { refreshAuth, useAuth } from "@/lib/auth";
36
+ import { type SettingsSection, settingsPath } from "@/lib/routes";
37
+ import { cn } from "@/lib/utils";
38
+ import type { AuthStatus } from "@/server/services/auth";
39
+ import logo from "../../logo.svg";
40
+
41
+ /** Logo aplikasi sebagai ikon navigasi "About KCG Code". */
42
+ function AppIcon({ className }: { className?: string }) {
43
+ return <img src={logo} alt="" className={cn("rounded-[22%]", className)} />;
44
+ }
45
+
46
+ export interface SettingsPageInfo {
47
+ id: SettingsSection;
48
+ label: string;
49
+ /** Subjudul baris di daftar bagian (layar sempit). */
50
+ description: string;
51
+ icon: ComponentType<{ className?: string }>;
52
+ }
53
+
54
+ /** Bagian pengaturan (urutan navigasi). About sengaja terpisah. */
55
+ export const SETTINGS_PANELS: readonly SettingsPageInfo[] = [
56
+ {
57
+ id: "profile",
58
+ label: "Profile",
59
+ description: "Name, picture & account",
60
+ icon: UserIcon,
61
+ },
62
+ {
63
+ id: "security",
64
+ label: "Security",
65
+ description: "App lock, passkeys & devices",
66
+ icon: ShieldIcon,
67
+ },
68
+ {
69
+ id: "workspace",
70
+ label: "Workspace",
71
+ description: "Where your projects live",
72
+ icon: FolderIcon,
73
+ },
74
+ {
75
+ id: "remote",
76
+ label: "Remote access",
77
+ description: "Open KCG Code from anywhere",
78
+ icon: GlobeIcon,
79
+ },
80
+ ];
81
+
82
+ /** Entri "About" — selalu terakhir, dipisah dari bagian pengaturan. */
83
+ export const SETTINGS_ABOUT: SettingsPageInfo = {
84
+ id: "about",
85
+ label: "About KCG Code",
86
+ description: "Version & credits",
87
+ icon: AppIcon,
88
+ };
89
+
90
+ function pageInfo(section: SettingsSection): SettingsPageInfo {
91
+ return SETTINGS_PANELS.find((p) => p.id === section) ?? SETTINGS_ABOUT;
92
+ }
93
+
94
+ /** Breakpoint `lg` — di atasnya navigasi & isi berdampingan. */
95
+ const WIDE_QUERY = "(min-width: 1024px)";
96
+
97
+ function errorText(e: unknown, fallback: string): string {
98
+ return e instanceof ApiError ? e.message : fallback;
99
+ }
100
+
101
+ /** Tautan SPA (Ctrl/Cmd-klik tetap membuka tab baru). */
102
+ function useNavLink() {
103
+ const { navigate } = useRouter();
104
+ return (href: string) => (e: React.MouseEvent<HTMLAnchorElement>) => {
105
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
106
+ e.preventDefault();
107
+ navigate(href);
108
+ };
109
+ }
110
+
111
+ export function SettingsPage({
112
+ section,
113
+ anchor,
114
+ root = false,
115
+ }: {
116
+ section: SettingsSection;
117
+ /** Grup yang dituju (mis. URL lama `/settings/devices` -> "devices"). */
118
+ anchor?: string;
119
+ /** `/settings` tanpa sub-halaman (layar sempit: daftar bagian). */
120
+ root?: boolean;
121
+ }) {
122
+ const auth = useAuth();
123
+ const status = auth.phase === "ready" ? auth.status : null;
124
+ const ok = useCallback((text: string) => toast.success(text), []);
125
+ const error = useCallback((text: string) => toast.error(text), []);
126
+ const ready = status !== null;
127
+ const wide = useMediaQuery(WIDE_QUERY);
128
+
129
+ // Gulir ke grup tujuan setelah kontennya dirender.
130
+ useEffect(() => {
131
+ if (!anchor || !ready) return;
132
+ const t = setTimeout(() => {
133
+ document.getElementById(anchor)?.scrollIntoView({ block: "start", behavior: "smooth" });
134
+ }, 100);
135
+ return () => clearTimeout(t);
136
+ }, [anchor, ready]);
137
+
138
+ const content = !status ? (
139
+ <div className="flex justify-center py-16" role="status">
140
+ <Spinner className="size-6 text-muted-foreground" />
141
+ <span className="sr-only">Loading settings…</span>
142
+ </div>
143
+ ) : (
144
+ <div className="flex flex-col gap-8">
145
+ {section === "profile" && <ProfileSection status={status} onOk={ok} onError={error} />}
146
+ {section === "security" && <SecurityGroup status={status} onOk={ok} />}
147
+ {section === "workspace" && <WorkspaceGroup status={status} />}
148
+ {section === "remote" && <RemoteAccessGroup protectedApp={status.protected} />}
149
+ {section === "about" && <AboutGroup />}
150
+ </div>
151
+ );
152
+
153
+ // Lebar: AdwNavigationSplitView — sidebar navigasi + isi.
154
+ if (wide) {
155
+ return (
156
+ <div className="grid grid-cols-[14.5rem_minmax(0,1fr)] gap-10 pt-2 pb-10">
157
+ <SettingsSidebar current={section} />
158
+ <div className="flex min-w-0 max-w-[40rem] flex-col gap-6">
159
+ <h2 className="px-1 text-2xl font-semibold tracking-tight">{pageInfo(section).label}</h2>
160
+ {content}
161
+ </div>
162
+ </div>
163
+ );
164
+ }
165
+
166
+ // Sempit, `/settings`: daftar bagian.
167
+ if (root) return <SettingsIndex status={status} />;
168
+
169
+ // Sempit, sub-halaman: tombol kembali + judul di tengah.
170
+ return (
171
+ <div className="mx-auto flex w-full max-w-[40rem] flex-col gap-6 pb-[max(2.5rem,env(safe-area-inset-bottom))]">
172
+ <SubpageHeader title={pageInfo(section).label} />
173
+ {content}
174
+ </div>
175
+ );
176
+ }
177
+
178
+ // ---------------------------------------------------------------- nav ----
179
+
180
+ /**
181
+ * Sidebar navigasi (layar lebar) — gaya `.navigation-sidebar` libadwaita:
182
+ * baris ikon + label, pilihan aktif berlatar membulat. About di bawah garis
183
+ * pemisah, dengan logo aplikasi sebagai ikon.
184
+ */
185
+ function SettingsSidebar({ current }: { current: SettingsSection }) {
186
+ const link = useNavLink();
187
+ const item = ({ id, label, icon: Icon }: SettingsPageInfo) => {
188
+ const href = settingsPath(id);
189
+ const active = id === current;
190
+ return (
191
+ <li key={id}>
192
+ <a
193
+ href={href}
194
+ onClick={link(href)}
195
+ aria-current={active ? "page" : undefined}
196
+ className={cn(
197
+ "flex h-10 items-center gap-3 rounded-lg px-3 text-[15px] transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
198
+ active
199
+ ? "bg-black/[0.08] font-medium text-foreground dark:bg-white/[0.1]"
200
+ : "text-foreground/80 hover:bg-black/[0.04] hover:text-foreground dark:hover:bg-white/[0.05]",
201
+ )}
202
+ >
203
+ <Icon className="size-[18px] shrink-0" />
204
+ <span className="truncate">{label}</span>
205
+ </a>
206
+ </li>
207
+ );
208
+ };
209
+ return (
210
+ <nav aria-label="Settings" className="sticky top-2 flex flex-col gap-3 self-start">
211
+ <h1 className="px-3 text-2xl font-semibold tracking-tight">Settings</h1>
212
+ <ul className="flex flex-col gap-0.5">{SETTINGS_PANELS.map(item)}</ul>
213
+ <div className="mx-3 h-px bg-border" aria-hidden />
214
+ <ul className="flex flex-col gap-0.5">{item(SETTINGS_ABOUT)}</ul>
215
+ </nav>
216
+ );
217
+ }
218
+
219
+ /**
220
+ * Daftar bagian (layar sempit, `/settings`) — boxed list ala GNOME Settings
221
+ * mobile: profil di atas (avatar + nama), lalu bagian pengaturan, lalu
222
+ * About di grup sendiri.
223
+ */
224
+ function SettingsIndex({ status }: { status: AuthStatus | null }) {
225
+ const { navigate } = useRouter();
226
+ const profile = status?.profile;
227
+ const row = (p: SettingsPageInfo, overrides?: { title?: ReactNode; prefix?: ReactNode }) => (
228
+ <ActionRow
229
+ key={p.id}
230
+ prefix={overrides?.prefix ?? <p.icon className="size-[18px] text-muted-foreground" />}
231
+ title={overrides?.title ?? p.label}
232
+ subtitle={p.description}
233
+ onActivate={() => navigate(settingsPath(p.id))}
234
+ />
235
+ );
236
+ const [profilePanel, ...panels] = SETTINGS_PANELS;
237
+
238
+ return (
239
+ <div className="mx-auto flex w-full max-w-[40rem] flex-col gap-6 pb-[max(2.5rem,env(safe-area-inset-bottom))]">
240
+ <h1 className="px-1 text-2xl font-semibold tracking-tight">Settings</h1>
241
+ {profilePanel && (
242
+ <PrefsGroup>
243
+ {row(profilePanel, {
244
+ title: profile?.nickname ?? "Profile",
245
+ prefix: (
246
+ <ProfileAvatar
247
+ nickname={profile?.nickname ?? null}
248
+ avatarUrl={profile?.avatarUrl ?? null}
249
+ avatarPreset={profile?.avatarPreset ?? null}
250
+ className="size-10"
251
+ textClassName="text-base"
252
+ />
253
+ ),
254
+ })}
255
+ </PrefsGroup>
256
+ )}
257
+ <PrefsGroup>{panels.map((p) => row(p))}</PrefsGroup>
258
+ <PrefsGroup>
259
+ {row(SETTINGS_ABOUT, { prefix: <AppIcon className="size-[22px]" /> })}
260
+ </PrefsGroup>
261
+ </div>
262
+ );
263
+ }
264
+
265
+ /** Header sub-halaman (layar sempit): "‹ Settings" + judul di tengah. */
266
+ function SubpageHeader({ title }: { title: string }) {
267
+ const link = useNavLink();
268
+ const href = settingsPath();
269
+ return (
270
+ <header className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
271
+ <a
272
+ href={href}
273
+ onClick={link(href)}
274
+ className="-ml-2 flex w-fit items-center gap-0.5 rounded-full py-1.5 pr-3 pl-1.5 text-[15px] font-medium outline-none hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50 active:bg-muted"
275
+ >
276
+ <ChevronLeftIcon className="size-5" aria-hidden />
277
+ Settings
278
+ </a>
279
+ <h1 className="truncate text-center text-[17px] font-bold">{title}</h1>
280
+ <span aria-hidden />
281
+ </header>
282
+ );
283
+ }
284
+
285
+ // ----------------------------------------------------------- profile ----
286
+
287
+ function ProfileSection({
288
+ status,
289
+ onOk,
290
+ onError,
291
+ }: {
292
+ status: AuthStatus;
293
+ onOk: (t: string) => void;
294
+ onError: (t: string) => void;
295
+ }) {
296
+ const saveNickname = async (next: string): Promise<string | null> => {
297
+ try {
298
+ await apiFetch("/api/auth/profile", {
299
+ method: "PATCH",
300
+ body: JSON.stringify({ nickname: next || null }),
301
+ });
302
+ await refreshAuth();
303
+ onOk("Nickname saved.");
304
+ return null;
305
+ } catch (err) {
306
+ return errorText(err, "Couldn't save nickname.");
307
+ }
308
+ };
309
+
310
+ return (
311
+ <>
312
+ <div className="flex flex-col gap-5">
313
+ <div className="flex flex-col items-center gap-3 pt-1 text-center">
314
+ <AvatarEditor
315
+ profile={status.profile}
316
+ onResult={(r) => ("ok" in r ? onOk(r.ok) : onError(r.error))}
317
+ />
318
+ <h2 className="max-w-full truncate px-2 text-xl font-semibold tracking-tight sm:text-2xl">
319
+ {status.profile.nickname ?? "Your profile"}
320
+ </h2>
321
+ </div>
322
+
323
+ <PrefsGroup
324
+ title="Profile"
325
+ description="Your name and picture appear on the lock screen and sidebar."
326
+ >
327
+ <EntryRow
328
+ title="Nickname"
329
+ value={status.profile.nickname ?? ""}
330
+ placeholder="Add a nickname"
331
+ maxLength={40}
332
+ autoComplete="nickname"
333
+ onSave={saveNickname}
334
+ />
335
+ </PrefsGroup>
336
+ </div>
337
+
338
+ <AccountGroup />
339
+ </>
340
+ );
341
+ }
342
+
343
+ /** Tautan antar sub-halaman Settings. */
344
+ export function SectionLink({
345
+ section,
346
+ children,
347
+ }: {
348
+ section: SettingsSection;
349
+ children: React.ReactNode;
350
+ }) {
351
+ const { navigate } = useRouter();
352
+ const href = settingsPath(section);
353
+ return (
354
+ <a
355
+ href={href}
356
+ onClick={(e) => {
357
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
358
+ e.preventDefault();
359
+ navigate(href);
360
+ }}
361
+ className="font-medium text-foreground underline underline-offset-2"
362
+ >
363
+ {children}
364
+ </a>
365
+ );
366
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Navigasi Settings — bagian pengaturan dibatasi (tidak sesak), About
3
+ * terpisah di akhir navigasi.
4
+ */
5
+ import { expect, test } from "bun:test";
6
+ import { SETTINGS_SECTIONS } from "@/lib/routes";
7
+ import { SETTINGS_ABOUT, SETTINGS_PANELS } from "../SettingsPage";
8
+
9
+ test("empat bagian pengaturan: Devices bagian dari Security", () => {
10
+ expect(SETTINGS_PANELS.map((p) => p.id)).toEqual(["profile", "security", "workspace", "remote"]);
11
+ });
12
+
13
+ test("About entri terpisah; navigasi mencakup semua sub-halaman", () => {
14
+ expect(SETTINGS_ABOUT.id).toBe("about");
15
+ expect([...SETTINGS_PANELS.map((p) => p.id), SETTINGS_ABOUT.id]).toEqual([...SETTINGS_SECTIONS]);
16
+ });