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,265 @@
1
+ /**
2
+ * Primitif halaman preferensi bergaya GNOME Settings / libadwaita:
3
+ * - `PrefsGroup` judul + deskripsi grup, isi = "boxed list" (kartu
4
+ * membulat, baris berpemisah).
5
+ * - `ActionRow` judul & subjudul di kiri, konten (suffix) di kanan. Bisa
6
+ * diklik (tombol / tautan) dengan chevron seperti GNOME.
7
+ * - `EntryRow` label kecil di atas input sebaris; tombol ✓ muncul saat
8
+ * nilai berubah (seperti AdwEntryRow "apply button").
9
+ * - `SwitchRow` tidak dibutuhkan saat ini; `ComboRow` diwakili ActionRow +
10
+ * Select di suffix.
11
+ */
12
+ import { CheckIcon, ChevronRightIcon } from "lucide-react";
13
+ import { type ReactNode, useId, useState } from "react";
14
+ import { Spinner } from "@/components/ui/spinner";
15
+ import { cn } from "@/lib/utils";
16
+
17
+ export function PrefsGroup({
18
+ id,
19
+ title,
20
+ description,
21
+ suffix,
22
+ children,
23
+ }: {
24
+ id?: string;
25
+ title?: string;
26
+ description?: ReactNode;
27
+ /** Aksi kecil di kanan judul grup (mis. tombol). */
28
+ suffix?: ReactNode;
29
+ children: ReactNode;
30
+ }) {
31
+ const headingId = useId();
32
+ return (
33
+ <section
34
+ id={id}
35
+ aria-labelledby={title ? headingId : undefined}
36
+ className="flex scroll-mt-6 flex-col gap-2.5"
37
+ >
38
+ {(title || suffix) && (
39
+ // Suffix sebaris dengan judul (bukan deskripsi) agar deskripsi tetap
40
+ // selebar kolom di layar sempit, tidak terjepit tombol di kanannya.
41
+ <div className="flex flex-col gap-0.5 px-1">
42
+ <div className="flex min-h-8 items-center justify-between gap-3">
43
+ {title && (
44
+ <h2 id={headingId} className="min-w-0 text-[15px] font-semibold tracking-tight">
45
+ {title}
46
+ </h2>
47
+ )}
48
+ {suffix && <div className="-mr-2 ml-auto shrink-0">{suffix}</div>}
49
+ </div>
50
+ {description && <p className="text-[13px] text-muted-foreground">{description}</p>}
51
+ </div>
52
+ )}
53
+ <div className="overflow-hidden rounded-xl border bg-card shadow-xs">
54
+ <ul className="flex flex-col divide-y">{children}</ul>
55
+ </div>
56
+ </section>
57
+ );
58
+ }
59
+
60
+ interface ActionRowProps {
61
+ title: ReactNode;
62
+ subtitle?: ReactNode;
63
+ /** Ikon / avatar di kiri. */
64
+ prefix?: ReactNode;
65
+ /** Kontrol / nilai di kanan. */
66
+ suffix?: ReactNode;
67
+ /** Baris bisa diklik -> tampilkan chevron. */
68
+ onActivate?: () => void;
69
+ /** Gaya merah (aksi berbahaya). */
70
+ destructive?: boolean;
71
+ disabled?: boolean;
72
+ /** Tautkan `<label>` ke kontrol di suffix. */
73
+ htmlFor?: string;
74
+ /**
75
+ * Di layar sempit (< sm) suffix selalu turun ke bawah teks. Cocok untuk
76
+ * baris dengan subjudul panjang + tombol, agar subjudul tidak terjepit.
77
+ * Tanpa ini suffix tetap turun otomatis bila ruang teks < 10rem.
78
+ */
79
+ stack?: boolean;
80
+ className?: string;
81
+ }
82
+
83
+ export function ActionRow({
84
+ title,
85
+ subtitle,
86
+ prefix,
87
+ suffix,
88
+ onActivate,
89
+ destructive,
90
+ disabled,
91
+ htmlFor,
92
+ stack,
93
+ className,
94
+ }: ActionRowProps) {
95
+ const titleClass = cn("text-[15px] leading-5", destructive && "text-destructive");
96
+ const body = (
97
+ <>
98
+ {prefix && <span className="flex shrink-0 items-center">{prefix}</span>}
99
+ {/* Teks + suffix dalam flex-wrap: suffix pindah ke baris berikutnya
100
+ (rata kiri dengan teks) saat lebar tidak cukup, alih-alih memeras
101
+ subjudul jadi kolom sempit. */}
102
+ <span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-2">
103
+ <span
104
+ className={cn(
105
+ "flex min-w-[min(100%,10rem)] flex-1 flex-col gap-0.5 py-0.5",
106
+ stack && "basis-full sm:basis-0",
107
+ )}
108
+ >
109
+ {htmlFor ? (
110
+ <label htmlFor={htmlFor} className={titleClass}>
111
+ {title}
112
+ </label>
113
+ ) : (
114
+ <span className={titleClass}>{title}</span>
115
+ )}
116
+ {subtitle && (
117
+ <span className="text-[13px] leading-snug text-muted-foreground">{subtitle}</span>
118
+ )}
119
+ </span>
120
+ {suffix && <span className="flex shrink-0 items-center gap-2">{suffix}</span>}
121
+ </span>
122
+ {onActivate && (
123
+ <ChevronRightIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
124
+ )}
125
+ </>
126
+ );
127
+
128
+ const base = "flex min-h-14 w-full items-center gap-3 px-4 py-2.5 text-left";
129
+
130
+ return (
131
+ <li className={className}>
132
+ {onActivate ? (
133
+ <button
134
+ type="button"
135
+ onClick={onActivate}
136
+ disabled={disabled}
137
+ className={cn(
138
+ base,
139
+ "transition-colors outline-none hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:ring-[3px] focus-visible:ring-ring/40 focus-visible:ring-inset active:bg-muted disabled:pointer-events-none disabled:opacity-50",
140
+ )}
141
+ >
142
+ {body}
143
+ </button>
144
+ ) : (
145
+ <div className={base}>{body}</div>
146
+ )}
147
+ </li>
148
+ );
149
+ }
150
+
151
+ /**
152
+ * Baris input ala AdwEntryRow: label kecil di atas nilai; tombol ✓ muncul
153
+ * saat nilai berubah. Enter menyimpan, Esc mengembalikan nilai awal.
154
+ */
155
+ export function EntryRow({
156
+ title,
157
+ value,
158
+ placeholder,
159
+ maxLength,
160
+ autoComplete,
161
+ onSave,
162
+ }: {
163
+ title: string;
164
+ value: string;
165
+ placeholder?: string;
166
+ maxLength?: number;
167
+ autoComplete?: string;
168
+ /** Kembalikan pesan error untuk ditampilkan, atau null bila sukses. */
169
+ onSave: (next: string) => Promise<string | null>;
170
+ }) {
171
+ const id = useId();
172
+ const [draft, setDraft] = useState(value);
173
+ const [saving, setSaving] = useState(false);
174
+ const [error, setError] = useState<string | null>(null);
175
+ const [lastValue, setLastValue] = useState(value);
176
+ // Nilai dari server berubah (disimpan di tempat lain) -> sinkronkan draft.
177
+ if (value !== lastValue) {
178
+ setLastValue(value);
179
+ setDraft(value);
180
+ }
181
+ const dirty = draft.trim() !== value;
182
+
183
+ const save = async () => {
184
+ if (!dirty || saving) return;
185
+ setSaving(true);
186
+ setError(await onSave(draft.trim()));
187
+ setSaving(false);
188
+ };
189
+
190
+ return (
191
+ <li>
192
+ <form
193
+ className="flex min-h-14 items-center gap-3 px-4 py-2 focus-within:bg-muted/30"
194
+ onSubmit={(e) => {
195
+ e.preventDefault();
196
+ void save();
197
+ }}
198
+ >
199
+ <div className="flex min-w-0 flex-1 flex-col">
200
+ <label htmlFor={id} className="text-[12px] leading-4 text-muted-foreground">
201
+ {title}
202
+ </label>
203
+ <input
204
+ id={id}
205
+ value={draft}
206
+ maxLength={maxLength}
207
+ placeholder={placeholder}
208
+ autoComplete={autoComplete}
209
+ aria-invalid={error ? true : undefined}
210
+ aria-describedby={error ? `${id}-err` : undefined}
211
+ onChange={(e) => {
212
+ setDraft(e.target.value);
213
+ setError(null);
214
+ }}
215
+ onKeyDown={(e) => {
216
+ if (e.key === "Escape") {
217
+ setDraft(value);
218
+ setError(null);
219
+ }
220
+ }}
221
+ // 16px di mobile: iOS Safari men-zoom halaman saat fokus ke input < 16px.
222
+ className="h-7 w-full min-w-0 bg-transparent text-base outline-none placeholder:text-muted-foreground/70 sm:text-[15px]"
223
+ />
224
+ {error && (
225
+ <span id={`${id}-err`} role="alert" className="text-[12px] text-destructive">
226
+ {error}
227
+ </span>
228
+ )}
229
+ </div>
230
+ {dirty && (
231
+ <button
232
+ type="submit"
233
+ aria-label={`Save ${title.toLowerCase()}`}
234
+ disabled={saving}
235
+ className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-xs transition-opacity outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-60"
236
+ >
237
+ {saving ? <Spinner className="size-4" /> : <CheckIcon className="size-4" />}
238
+ </button>
239
+ )}
240
+ </form>
241
+ </li>
242
+ );
243
+ }
244
+
245
+ /** Pil status kecil untuk suffix baris (mis. "On" / "Off"). */
246
+ export function RowBadge({
247
+ tone = "muted",
248
+ children,
249
+ }: {
250
+ tone?: "muted" | "success" | "warning";
251
+ children: ReactNode;
252
+ }) {
253
+ return (
254
+ <span
255
+ className={cn(
256
+ "rounded-full px-2 py-0.5 text-[12px] font-medium",
257
+ tone === "success" && "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
258
+ tone === "warning" && "bg-amber-500/15 text-amber-800 dark:text-amber-300",
259
+ tone === "muted" && "bg-muted text-muted-foreground",
260
+ )}
261
+ >
262
+ {children}
263
+ </span>
264
+ );
265
+ }
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Notifikasi instalasi skill — tumpukan kartu di pojok kanan bawah (lebar
3
+ * penuh di HP), dipasang sekali di `AppShell` sehingga tetap tampil di
4
+ * halaman mana pun.
5
+ *
6
+ * Tiap kartu: status (spinner / centang / silang), nama skill -> Project,
7
+ * waktu berjalan, dan panel output CLI (gaya terminal) yang bisa dibuka-
8
+ * tutup dan otomatis menggulir ke baris terbaru — kecuali user sedang
9
+ * menggulir ke atas membaca log.
10
+ *
11
+ * Aksesibilitas: wadah `role="region"`; perubahan status diumumkan lewat
12
+ * satu `aria-live` ringkas (bukan tiap baris log, agar tidak berisik).
13
+ */
14
+ import {
15
+ BanIcon,
16
+ CheckCircle2Icon,
17
+ ChevronDownIcon,
18
+ ClockIcon,
19
+ CopyIcon,
20
+ SquareIcon,
21
+ TerminalIcon,
22
+ XCircleIcon,
23
+ XIcon,
24
+ } from "lucide-react";
25
+ import { useEffect, useRef, useState } from "react";
26
+ import { Spinner } from "@/components/ui/spinner";
27
+ import { apiErrorMessage } from "@/lib/api";
28
+ import {
29
+ cancelInstall,
30
+ dismissInstall,
31
+ hydrateInstalls,
32
+ type InstallEntry,
33
+ toggleInstallLog,
34
+ useInstalls,
35
+ } from "@/lib/skill-installs";
36
+ import { cn } from "@/lib/utils";
37
+
38
+ /** Kartu yang tampil bersamaan; sisanya diringkas "+N more". */
39
+ const MAX_VISIBLE = 3;
40
+
41
+ export function InstallNotifications() {
42
+ const all = useInstalls();
43
+
44
+ useEffect(() => {
45
+ void hydrateInstalls();
46
+ }, []);
47
+
48
+ const visible = all.filter((e) => !e.dismissed);
49
+ const shown = visible.slice(0, MAX_VISIBLE);
50
+ const hidden = visible.length - shown.length;
51
+ const running = all.filter((e) => e.job.status === "running").length;
52
+
53
+ return (
54
+ <>
55
+ {/* Pengumuman ringkas untuk pembaca layar. */}
56
+ <p className="sr-only" aria-live="polite">
57
+ {running > 0 ? `${running} skill installation${running > 1 ? "s" : ""} in progress.` : ""}
58
+ </p>
59
+ {shown.length > 0 && (
60
+ <section
61
+ aria-label="Skill installations"
62
+ className="pointer-events-none fixed inset-x-3 bottom-3 z-50 flex flex-col-reverse gap-2 sm:inset-x-auto sm:right-4 sm:bottom-4 sm:w-[26rem]"
63
+ >
64
+ {shown.map((entry) => (
65
+ <InstallCard key={entry.job.id} entry={entry} />
66
+ ))}
67
+ {hidden > 0 && (
68
+ <p className="pointer-events-auto self-end rounded-full border bg-popover px-3 py-1 text-xs text-muted-foreground shadow-md">
69
+ +{hidden} more
70
+ </p>
71
+ )}
72
+ </section>
73
+ )}
74
+ </>
75
+ );
76
+ }
77
+
78
+ /** Detik berjalan (di-tick tiap detik selama job belum selesai). */
79
+ function useNow(active: boolean): number {
80
+ const [now, setNow] = useState(() => Date.now());
81
+ useEffect(() => {
82
+ if (!active) return;
83
+ const t = setInterval(() => setNow(Date.now()), 1000);
84
+ return () => clearInterval(t);
85
+ }, [active]);
86
+ return now;
87
+ }
88
+
89
+ function formatSeconds(ms: number): string {
90
+ const s = Math.max(0, Math.round(ms / 1000));
91
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
92
+ }
93
+
94
+ /** Diam selama ini -> tampilkan petunjuk "belum ada output". */
95
+ const QUIET_HINT_MS = 15_000;
96
+
97
+ function InstallCard({ entry }: { entry: InstallEntry }) {
98
+ const { job, lines, expanded, pollError, truncated, cancelling } = entry;
99
+ const running = job.status === "running";
100
+ const failed = job.status === "failed";
101
+ const cancelled = job.status === "cancelled";
102
+ const now = useNow(running);
103
+ const elapsed = formatSeconds((job.finishedAt ?? now) - job.startedAt);
104
+ const quietMs = running ? now - job.lastOutputAt : 0;
105
+ const logId = `install-log-${job.id}`;
106
+
107
+ const title = running
108
+ ? cancelling
109
+ ? `Cancelling ${job.skillId}…`
110
+ : `Installing ${job.skillId}`
111
+ : cancelled
112
+ ? `Cancelled ${job.skillId}`
113
+ : failed
114
+ ? `Couldn't install ${job.skillId}`
115
+ : `Installed ${job.skillId}`;
116
+
117
+ /** Terpasang di disk, tapi agent belum memuat ulang skill (chat berjalan). */
118
+ const awaitingActivation = job.status === "succeeded" && job.refreshed === false;
119
+
120
+ const subtitle = cancelled
121
+ ? `Nothing was added to ${job.projectName}.`
122
+ : failed
123
+ ? apiErrorMessage(job.error ?? "SKILL_INSTALL_FAILED")
124
+ : awaitingActivation
125
+ ? `Added to ${job.projectName}. Activates automatically when the running chat finishes.`
126
+ : `${running ? "into" : "Added to"} ${job.projectName}${
127
+ job.status === "succeeded" ? " · active" : ""
128
+ }`;
129
+
130
+ return (
131
+ <article
132
+ aria-label={title}
133
+ className={cn(
134
+ "pointer-events-auto overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg",
135
+ "animate-in fade-in-0 slide-in-from-bottom-2",
136
+ failed && "border-destructive/40",
137
+ )}
138
+ >
139
+ <div className="flex items-start gap-3 p-3.5">
140
+ <span className="mt-0.5 flex size-5 shrink-0 items-center justify-center" aria-hidden>
141
+ {running ? (
142
+ <Spinner className="size-4 text-primary" />
143
+ ) : cancelled ? (
144
+ <BanIcon className="size-5 text-muted-foreground" />
145
+ ) : failed ? (
146
+ <XCircleIcon className="size-5 text-destructive" />
147
+ ) : awaitingActivation ? (
148
+ <ClockIcon className="size-5 text-amber-600 dark:text-amber-400" />
149
+ ) : (
150
+ <CheckCircle2Icon className="size-5 text-emerald-600 dark:text-emerald-400" />
151
+ )}
152
+ </span>
153
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
154
+ <p className="flex min-w-0 items-baseline gap-2">
155
+ <span className="truncate text-sm font-semibold">{title}</span>
156
+ <span className="shrink-0 text-xs tabular-nums text-muted-foreground">{elapsed}</span>
157
+ </p>
158
+ <p
159
+ className={cn(
160
+ "line-clamp-2 text-xs",
161
+ failed ? "text-destructive" : "text-muted-foreground",
162
+ )}
163
+ >
164
+ {subtitle}
165
+ </p>
166
+ {awaitingActivation && (
167
+ <span className="mt-0.5 flex w-fit items-center gap-1.5 rounded-full bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-700 dark:text-amber-400">
168
+ <span className="size-1.5 animate-pulse rounded-full bg-current" aria-hidden />
169
+ Waiting for chat to finish
170
+ </span>
171
+ )}
172
+ {pollError && <p className="text-xs text-amber-600 dark:text-amber-400">{pollError}</p>}
173
+ {running && !cancelling && quietMs >= QUIET_HINT_MS && (
174
+ <p className="text-xs text-amber-600 dark:text-amber-400">
175
+ No output for {formatSeconds(quietMs)} — still downloading, or you can cancel.
176
+ </p>
177
+ )}
178
+ <span className="truncate font-mono text-[11px] text-muted-foreground/80">
179
+ {job.source}
180
+ </span>
181
+ </div>
182
+ <div className="-mt-0.5 -mr-1 flex shrink-0 items-center gap-0.5">
183
+ {running && (
184
+ <button
185
+ type="button"
186
+ onClick={() => void cancelInstall(job.id)}
187
+ disabled={cancelling}
188
+ aria-label={`Cancel installing ${job.skillId}`}
189
+ title="Cancel installation"
190
+ className="flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50"
191
+ >
192
+ <SquareIcon className="size-3 fill-current" aria-hidden />
193
+ Cancel
194
+ </button>
195
+ )}
196
+ <button
197
+ type="button"
198
+ onClick={() => dismissInstall(job.id)}
199
+ aria-label={running ? "Hide (installation continues)" : "Dismiss"}
200
+ title={running ? "Hide — installation continues in the background" : "Dismiss"}
201
+ className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
202
+ >
203
+ <XIcon className="size-4" />
204
+ </button>
205
+ </div>
206
+ </div>
207
+
208
+ {/* Indeterminate progress selama berjalan. */}
209
+ {running && (
210
+ <div className="h-0.5 w-full overflow-hidden bg-muted" aria-hidden>
211
+ <div className="h-full w-1/3 animate-[install-progress_1.4s_ease-in-out_infinite] rounded-full bg-primary" />
212
+ </div>
213
+ )}
214
+
215
+ <div className="border-t">
216
+ <button
217
+ type="button"
218
+ onClick={() => toggleInstallLog(job.id)}
219
+ aria-expanded={expanded}
220
+ aria-controls={logId}
221
+ className="flex w-full items-center gap-2 px-3.5 py-2 text-xs font-medium text-muted-foreground hover:bg-muted/50 hover:text-foreground"
222
+ >
223
+ <TerminalIcon className="size-3.5" aria-hidden />
224
+ {expanded ? "Hide output" : "Show output"}
225
+ <span className="tabular-nums text-muted-foreground/70">
226
+ {lines.length > 0 && `· ${lines.length} lines`}
227
+ </span>
228
+ <ChevronDownIcon
229
+ className={cn("ml-auto size-3.5 transition-transform", expanded && "rotate-180")}
230
+ aria-hidden
231
+ />
232
+ </button>
233
+ {expanded && <LogPanel id={logId} lines={lines} running={running} truncated={truncated} />}
234
+ </div>
235
+ </article>
236
+ );
237
+ }
238
+
239
+ function LogPanel({
240
+ id,
241
+ lines,
242
+ running,
243
+ truncated,
244
+ }: {
245
+ id: string;
246
+ lines: string[];
247
+ running: boolean;
248
+ truncated: boolean;
249
+ }) {
250
+ const ref = useRef<HTMLPreElement>(null);
251
+ /** Ikuti baris terbaru selama user tidak menggulir ke atas. */
252
+ const stick = useRef(true);
253
+ const [copied, setCopied] = useState(false);
254
+
255
+ // biome-ignore lint/correctness/useExhaustiveDependencies: gulir tiap ada baris baru
256
+ useEffect(() => {
257
+ const el = ref.current;
258
+ if (el && stick.current) el.scrollTop = el.scrollHeight;
259
+ }, [lines.length]);
260
+
261
+ const copy = async () => {
262
+ try {
263
+ await navigator.clipboard.writeText(lines.join("\n"));
264
+ setCopied(true);
265
+ setTimeout(() => setCopied(false), 1500);
266
+ } catch {
267
+ // Clipboard tidak tersedia (HTTP non-localhost) — abaikan.
268
+ }
269
+ };
270
+
271
+ return (
272
+ <div className="relative">
273
+ {/* role="log": wilayah output berurutan; fokusable agar bisa digulir
274
+ dengan keyboard. Tidak `aria-live` — pembaruan status diumumkan
275
+ terpisah supaya pembaca layar tidak membacakan tiap baris. */}
276
+ <pre
277
+ ref={ref}
278
+ id={id}
279
+ role="log"
280
+ aria-live="off"
281
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: area gulir harus bisa difokus keyboard
282
+ tabIndex={0}
283
+ aria-label="Installation output"
284
+ onScroll={(e) => {
285
+ const el = e.currentTarget;
286
+ stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
287
+ }}
288
+ className="max-h-56 overflow-auto bg-zinc-950 px-3.5 py-3 font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-words text-zinc-200 outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset"
289
+ >
290
+ {truncated && <span className="text-zinc-500">… earlier output trimmed{"\n"}</span>}
291
+ {lines.length === 0 ? (
292
+ <span className="text-zinc-500">Waiting for output…</span>
293
+ ) : (
294
+ lines.map((line, i) => (
295
+ // biome-ignore lint/suspicious/noArrayIndexKey: log append-only
296
+ <span key={i} className={lineClass(line)}>
297
+ {line}
298
+ {"\n"}
299
+ </span>
300
+ ))
301
+ )}
302
+ {running && <span className="animate-pulse text-zinc-400">▍</span>}
303
+ </pre>
304
+ {lines.length > 0 && (
305
+ <button
306
+ type="button"
307
+ onClick={() => void copy()}
308
+ aria-label="Copy output"
309
+ className="absolute top-2 right-2 flex h-6 items-center gap-1 rounded-md bg-zinc-800/90 px-2 text-[11px] text-zinc-300 hover:bg-zinc-700"
310
+ >
311
+ <CopyIcon className="size-3" aria-hidden />
312
+ {copied ? "Copied" : "Copy"}
313
+ </button>
314
+ )}
315
+ </div>
316
+ );
317
+ }
318
+
319
+ /** Warna baris log: perintah, sukses, gagal. */
320
+ function lineClass(line: string): string | undefined {
321
+ if (line.startsWith("$ ")) return "text-sky-300";
322
+ if (line.startsWith("✓")) return "text-emerald-400";
323
+ if (line.startsWith("✗") || /^(error|■)/i.test(line.trimStart())) return "text-red-400";
324
+ return undefined;
325
+ }