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,178 @@
1
+ /**
2
+ * Pemilih Project di halaman Skills — pil ringkas "Install to <project>"
3
+ * yang membuka popover berisi daftar Project dengan kotak cari.
4
+ *
5
+ * Kotak cari hanya muncul bila Project cukup banyak. Navigasi keyboard:
6
+ * ↑/↓ memindah sorotan, Enter memilih, Esc menutup (bawaan Popover).
7
+ */
8
+ import { CheckIcon, ChevronsUpDownIcon, FolderIcon, SearchIcon } from "lucide-react";
9
+ import { useEffect, useMemo, useRef, useState } from "react";
10
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
11
+ import { cn } from "@/lib/utils";
12
+ import type { Project } from "@/types";
13
+
14
+ /** Kotak cari tampil mulai jumlah Project ini. */
15
+ const SEARCH_THRESHOLD = 6;
16
+
17
+ export interface SkillsProjectPickerProps {
18
+ projects: Project[];
19
+ selected: Project | null;
20
+ onSelect: (projectId: string) => void;
21
+ }
22
+
23
+ export function SkillsProjectPicker({ projects, selected, onSelect }: SkillsProjectPickerProps) {
24
+ const [open, setOpen] = useState(false);
25
+ const [query, setQuery] = useState("");
26
+ const [highlight, setHighlight] = useState(0);
27
+ const listRef = useRef<HTMLDivElement>(null);
28
+ const searchRef = useRef<HTMLInputElement>(null);
29
+ const showSearch = projects.length >= SEARCH_THRESHOLD;
30
+
31
+ const filtered = useMemo(() => {
32
+ const q = query.trim().toLowerCase();
33
+ if (q === "") return projects;
34
+ return projects.filter(
35
+ (p) => p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q),
36
+ );
37
+ }, [projects, query]);
38
+
39
+ // Buka: reset pencarian, sorot Project terpilih.
40
+ useEffect(() => {
41
+ if (!open) return;
42
+ setQuery("");
43
+ const idx = projects.findIndex((p) => p.id === selected?.id);
44
+ setHighlight(idx >= 0 ? idx : 0);
45
+ }, [open, projects, selected?.id]);
46
+
47
+ // biome-ignore lint/correctness/useExhaustiveDependencies: reset saat query berubah
48
+ useEffect(() => {
49
+ setHighlight(0);
50
+ }, [query]);
51
+
52
+ useEffect(() => {
53
+ listRef.current
54
+ ?.querySelector<HTMLElement>(`[data-index="${highlight}"]`)
55
+ ?.scrollIntoView({ block: "nearest" });
56
+ }, [highlight]);
57
+
58
+ const pick = (p: Project | undefined) => {
59
+ if (!p) return;
60
+ onSelect(p.id);
61
+ setOpen(false);
62
+ };
63
+
64
+ const onKeyDown = (e: React.KeyboardEvent) => {
65
+ if (e.key === "ArrowDown") {
66
+ e.preventDefault();
67
+ setHighlight((h) => Math.min(h + 1, filtered.length - 1));
68
+ } else if (e.key === "ArrowUp") {
69
+ e.preventDefault();
70
+ setHighlight((h) => Math.max(h - 1, 0));
71
+ } else if (e.key === "Enter") {
72
+ e.preventDefault();
73
+ pick(filtered[highlight]);
74
+ }
75
+ };
76
+
77
+ const activeId = filtered[highlight] ? `skills-project-${highlight}` : undefined;
78
+
79
+ return (
80
+ <Popover open={open} onOpenChange={setOpen}>
81
+ <PopoverTrigger asChild>
82
+ {/* Pemilih konteks yang ringkas (pil), terpisah dari kolom cari:
83
+ menjawab "skill dipasang ke mana", bukan bagian dari pencarian. */}
84
+ <button
85
+ type="button"
86
+ aria-haspopup="listbox"
87
+ aria-label={
88
+ selected ? `Install to project ${selected.name}. Change project` : "Select a project"
89
+ }
90
+ className="group inline-flex h-9 max-w-full min-w-0 items-center gap-2 rounded-full border bg-card pr-2.5 pl-3 text-sm shadow-xs transition-colors outline-none hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[state=open]:border-ring"
91
+ >
92
+ <FolderIcon
93
+ className={cn("size-4 shrink-0", selected ? "text-primary" : "text-muted-foreground")}
94
+ aria-hidden
95
+ />
96
+ <span className="shrink-0 text-muted-foreground">Install to</span>
97
+ <span className={cn("truncate font-medium", !selected && "text-muted-foreground")}>
98
+ {selected?.name ?? "Select a project"}
99
+ </span>
100
+ <ChevronsUpDownIcon className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
101
+ </button>
102
+ </PopoverTrigger>
103
+ <PopoverContent
104
+ align="end"
105
+ className="w-80 max-w-[calc(100vw-2rem)] p-0"
106
+ onOpenAutoFocus={(e) => {
107
+ // Fokus ke kotak cari (bila ada), selain itu ke daftar agar ↑/↓ jalan.
108
+ e.preventDefault();
109
+ (searchRef.current ?? listRef.current)?.focus();
110
+ }}
111
+ >
112
+ {showSearch && (
113
+ <div className="flex items-center gap-2 border-b px-3">
114
+ <SearchIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
115
+ <input
116
+ ref={searchRef}
117
+ type="text"
118
+ role="combobox"
119
+ aria-expanded
120
+ aria-controls="skills-project-list"
121
+ aria-activedescendant={activeId}
122
+ aria-label="Search projects"
123
+ value={query}
124
+ onChange={(e) => setQuery(e.target.value)}
125
+ onKeyDown={onKeyDown}
126
+ placeholder="Search projects…"
127
+ className="h-10 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
128
+ />
129
+ </div>
130
+ )}
131
+ <div
132
+ ref={listRef}
133
+ id="skills-project-list"
134
+ role="listbox"
135
+ aria-label="Projects"
136
+ aria-activedescendant={showSearch ? undefined : activeId}
137
+ tabIndex={showSearch ? -1 : 0}
138
+ onKeyDown={showSearch ? undefined : onKeyDown}
139
+ className="max-h-72 overflow-y-auto p-1 outline-none"
140
+ >
141
+ {filtered.length === 0 ? (
142
+ <p className="px-3 py-6 text-center text-sm text-muted-foreground">
143
+ No projects match “{query.trim()}”.
144
+ </p>
145
+ ) : (
146
+ filtered.map((p, i) => {
147
+ const isSelected = p.id === selected?.id;
148
+ return (
149
+ <div
150
+ key={p.id}
151
+ id={`skills-project-${i}`}
152
+ data-index={i}
153
+ role="option"
154
+ aria-selected={isSelected}
155
+ tabIndex={-1}
156
+ onMouseMove={() => setHighlight(i)}
157
+ onClick={() => pick(p)}
158
+ onKeyDown={() => {}}
159
+ className={cn(
160
+ "flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-sm",
161
+ i === highlight && "bg-accent text-accent-foreground",
162
+ )}
163
+ >
164
+ <FolderIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
165
+ <span className="flex min-w-0 flex-1 flex-col">
166
+ <span className="truncate font-medium">{p.name}</span>
167
+ <span className="truncate text-xs text-muted-foreground">{p.path}</span>
168
+ </span>
169
+ {isSelected && <CheckIcon className="size-4 shrink-0" aria-hidden />}
170
+ </div>
171
+ );
172
+ })
173
+ )}
174
+ </div>
175
+ </PopoverContent>
176
+ </Popover>
177
+ );
178
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Kerangka dialog ala AdwDialog (libadwaita) di atas `DialogContent`:
3
+ * - Header bar: slot kiri (`start`), judul + subjudul di tengah
4
+ * (AdwWindowTitle), slot kanan (`end`). Tanpa `end` -> tombol tutup bundar.
5
+ * - Tinggi dipatok (isi panjang men-scroll di dalam), sudut 2xl.
6
+ * - Layar sempit (< sm): tampil sebagai bottom sheet dengan pegangan, seperti
7
+ * AdwDialog di jendela sempit; padding bawah menghormati safe-area iOS.
8
+ * - Fokus awal: desktop -> kolom cari pertama (bila ada); HP -> dialog itu
9
+ * sendiri agar keyboard virtual tidak langsung muncul.
10
+ * - Esc saat kolom cari berisi: kosongkan cari dulu (lihat `SearchEntry`),
11
+ * tidak menutup dialog.
12
+ */
13
+ import { XIcon } from "lucide-react";
14
+ import { type ReactNode, useRef } from "react";
15
+ import {
16
+ DialogClose,
17
+ DialogContent,
18
+ DialogDescription,
19
+ DialogHeader,
20
+ DialogTitle,
21
+ } from "@/components/ui/dialog";
22
+ import { cn } from "@/lib/utils";
23
+
24
+ export interface AdwDialogContentProps {
25
+ title: ReactNode;
26
+ subtitle?: ReactNode;
27
+ /** Aksi di kiri header bar (mis. "Cancel"). */
28
+ start?: ReactNode;
29
+ /** Aksi di kanan header bar (mis. tombol utama). Kosong -> tombol tutup. */
30
+ end?: ReactNode;
31
+ /** Kunci tombol tutup bawaan (mis. saat menyimpan). */
32
+ closeDisabled?: boolean;
33
+ /** Kelas area isi di bawah header bar. */
34
+ bodyClassName?: string;
35
+ children: ReactNode;
36
+ }
37
+
38
+ export function AdwDialogContent({
39
+ title,
40
+ subtitle,
41
+ start,
42
+ end,
43
+ closeDisabled = false,
44
+ bodyClassName,
45
+ children,
46
+ }: AdwDialogContentProps) {
47
+ const contentRef = useRef<HTMLDivElement>(null);
48
+ const hasSides = start !== undefined || end !== undefined;
49
+
50
+ return (
51
+ <DialogContent
52
+ ref={contentRef}
53
+ showCloseButton={false}
54
+ onOpenAutoFocus={(e) => {
55
+ e.preventDefault();
56
+ const wide = window.matchMedia("(min-width: 640px)").matches;
57
+ const search = contentRef.current?.querySelector<HTMLInputElement>('input[type="search"]');
58
+ (wide && search ? search : contentRef.current)?.focus();
59
+ }}
60
+ onEscapeKeyDown={(e) => {
61
+ const el = document.activeElement;
62
+ if (el instanceof HTMLInputElement && el.type === "search" && el.value !== "") {
63
+ e.preventDefault();
64
+ }
65
+ }}
66
+ className={cn(
67
+ "flex h-[min(640px,85dvh)] flex-col gap-0 overflow-hidden rounded-2xl p-0 sm:max-w-md",
68
+ "max-sm:top-auto max-sm:bottom-0 max-sm:left-0 max-sm:h-[85dvh] max-sm:max-w-none max-sm:translate-x-0 max-sm:translate-y-0 max-sm:rounded-b-none max-sm:border-x-0 max-sm:border-b-0",
69
+ "max-sm:data-[state=closed]:slide-out-to-bottom-8 max-sm:data-[state=open]:slide-in-from-bottom-8",
70
+ )}
71
+ >
72
+ {/* Pegangan sheet (dekoratif) — hanya di HP. */}
73
+ <span
74
+ aria-hidden
75
+ className="mx-auto mt-2 h-1 w-9 shrink-0 rounded-full bg-muted-foreground/30 sm:hidden"
76
+ />
77
+ <DialogHeader
78
+ className={cn(
79
+ "grid shrink-0 items-center gap-2 px-3 pt-2.5 pb-2 sm:pt-3",
80
+ // Kolom kiri/kanan selebar slot terlebar agar judul tetap di tengah.
81
+ hasSides ? "grid-cols-[1fr_auto_1fr]" : "grid-cols-[2rem_1fr_2rem]",
82
+ )}
83
+ >
84
+ <div className="flex min-w-0 justify-start">{start}</div>
85
+ <div className="flex min-w-0 flex-col items-center gap-0.5 text-center">
86
+ <DialogTitle className="truncate text-[15px] leading-5 font-bold">{title}</DialogTitle>
87
+ {subtitle && (
88
+ <DialogDescription className="truncate text-xs leading-4">{subtitle}</DialogDescription>
89
+ )}
90
+ </div>
91
+ <div className="flex min-w-0 justify-end">
92
+ {end ?? (
93
+ <DialogClose
94
+ disabled={closeDisabled}
95
+ aria-label="Close"
96
+ className="flex size-7 items-center justify-center rounded-full bg-black/[0.06] text-foreground/80 transition-colors outline-none hover:bg-black/10 focus-visible:ring-2 focus-visible:ring-ring/60 disabled:opacity-50 dark:bg-white/[0.08] dark:hover:bg-white/15"
97
+ >
98
+ <XIcon className="size-3.5" strokeWidth={2.5} />
99
+ </DialogClose>
100
+ )}
101
+ </div>
102
+ </DialogHeader>
103
+
104
+ <div
105
+ className={cn(
106
+ "flex min-h-0 flex-1 flex-col gap-3 px-4 pt-1 pb-[max(1rem,env(safe-area-inset-bottom))]",
107
+ bodyClassName,
108
+ )}
109
+ >
110
+ {children}
111
+ </div>
112
+ </DialogContent>
113
+ );
114
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Toggle group ala AdwToggleGroup (libadwaita 1.7): palung terisi berisi
3
+ * beberapa pilihan sejajar; pilihan aktif "terangkat" (latar terang +
4
+ * bayangan di mode terang, lebih terang dari palung di mode gelap).
5
+ *
6
+ * Satu pilihan selalu aktif (bukan multi-select). Tombol memakai
7
+ * `aria-pressed` di dalam `role="group"` berlabel.
8
+ */
9
+ import type { ReactNode } from "react";
10
+ import { cn } from "@/lib/utils";
11
+
12
+ export interface AdwToggleOption<T extends string> {
13
+ value: T;
14
+ label: ReactNode;
15
+ /** Ikon / logo di kiri label. */
16
+ icon?: ReactNode;
17
+ /** Label aksesibel bila `label` bukan teks biasa. */
18
+ ariaLabel?: string;
19
+ title?: string;
20
+ }
21
+
22
+ export interface AdwToggleGroupProps<T extends string> {
23
+ /** Label grup untuk pembaca layar. */
24
+ label: string;
25
+ value: T;
26
+ options: readonly AdwToggleOption<T>[];
27
+ onChange: (value: T) => void;
28
+ disabled?: boolean;
29
+ className?: string;
30
+ }
31
+
32
+ export function AdwToggleGroup<T extends string>({
33
+ label,
34
+ value,
35
+ options,
36
+ onChange,
37
+ disabled = false,
38
+ className,
39
+ }: AdwToggleGroupProps<T>) {
40
+ return (
41
+ // biome-ignore lint/a11y/useSemanticElements: grup tombol segmented, bukan fieldset form.
42
+ <div
43
+ role="group"
44
+ aria-label={label}
45
+ className={cn(
46
+ "grid shrink-0 auto-cols-fr grid-flow-col gap-0.5 rounded-[10px] bg-black/[0.06] p-[3px] dark:bg-white/[0.07]",
47
+ className,
48
+ )}
49
+ >
50
+ {options.map((o) => {
51
+ const active = o.value === value;
52
+ return (
53
+ <button
54
+ key={o.value}
55
+ type="button"
56
+ aria-pressed={active}
57
+ aria-label={o.ariaLabel}
58
+ title={o.title}
59
+ disabled={disabled}
60
+ onClick={() => onChange(o.value)}
61
+ className={cn(
62
+ "flex h-8 min-w-0 items-center justify-center gap-2 rounded-[7px] px-2 text-sm font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:opacity-60",
63
+ active
64
+ ? "bg-white text-foreground shadow-sm dark:bg-white/[0.15] dark:shadow-none"
65
+ : "text-foreground/70 hover:bg-black/[0.04] hover:text-foreground dark:hover:bg-white/[0.05]",
66
+ )}
67
+ >
68
+ {o.icon}
69
+ <span className="truncate">{o.label}</span>
70
+ </button>
71
+ );
72
+ })}
73
+ </div>
74
+ );
75
+ }
@@ -0,0 +1,179 @@
1
+ "use client";
2
+
3
+ import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
4
+ import type * as React from "react";
5
+
6
+ import { Button } from "@/components/ui/button";
7
+ import { cn } from "@/lib/utils";
8
+
9
+ function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
10
+ return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
11
+ }
12
+
13
+ function AlertDialogTrigger({
14
+ ...props
15
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
16
+ return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
17
+ }
18
+
19
+ function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
20
+ return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
21
+ }
22
+
23
+ function AlertDialogOverlay({
24
+ className,
25
+ ...props
26
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
27
+ return (
28
+ <AlertDialogPrimitive.Overlay
29
+ data-slot="alert-dialog-overlay"
30
+ className={cn(
31
+ "fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
32
+ className,
33
+ )}
34
+ {...props}
35
+ />
36
+ );
37
+ }
38
+
39
+ function AlertDialogContent({
40
+ className,
41
+ size = "default",
42
+ ...props
43
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
44
+ size?: "default" | "sm";
45
+ }) {
46
+ return (
47
+ <AlertDialogPortal>
48
+ <AlertDialogOverlay />
49
+ <AlertDialogPrimitive.Content
50
+ data-slot="alert-dialog-content"
51
+ data-size={size}
52
+ className={cn(
53
+ "group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
54
+ className,
55
+ )}
56
+ {...props}
57
+ />
58
+ </AlertDialogPortal>
59
+ );
60
+ }
61
+
62
+ function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
63
+ return (
64
+ <div
65
+ data-slot="alert-dialog-header"
66
+ className={cn(
67
+ "grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
68
+ className,
69
+ )}
70
+ {...props}
71
+ />
72
+ );
73
+ }
74
+
75
+ function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
76
+ return (
77
+ <div
78
+ data-slot="alert-dialog-footer"
79
+ className={cn(
80
+ "flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
81
+ className,
82
+ )}
83
+ {...props}
84
+ />
85
+ );
86
+ }
87
+
88
+ function AlertDialogTitle({
89
+ className,
90
+ ...props
91
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
92
+ return (
93
+ <AlertDialogPrimitive.Title
94
+ data-slot="alert-dialog-title"
95
+ className={cn(
96
+ "min-w-0 wrap-anywhere text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
97
+ className,
98
+ )}
99
+ {...props}
100
+ />
101
+ );
102
+ }
103
+
104
+ function AlertDialogDescription({
105
+ className,
106
+ ...props
107
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
108
+ return (
109
+ <AlertDialogPrimitive.Description
110
+ data-slot="alert-dialog-description"
111
+ className={cn("min-w-0 wrap-anywhere text-sm text-muted-foreground", className)}
112
+ {...props}
113
+ />
114
+ );
115
+ }
116
+
117
+ function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) {
118
+ return (
119
+ <div
120
+ data-slot="alert-dialog-media"
121
+ className={cn(
122
+ "mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
123
+ className,
124
+ )}
125
+ {...props}
126
+ />
127
+ );
128
+ }
129
+
130
+ function AlertDialogAction({
131
+ className,
132
+ variant = "default",
133
+ size = "default",
134
+ ...props
135
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
136
+ Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
137
+ return (
138
+ <Button variant={variant} size={size} asChild>
139
+ <AlertDialogPrimitive.Action
140
+ data-slot="alert-dialog-action"
141
+ className={cn(className)}
142
+ {...props}
143
+ />
144
+ </Button>
145
+ );
146
+ }
147
+
148
+ function AlertDialogCancel({
149
+ className,
150
+ variant = "outline",
151
+ size = "default",
152
+ ...props
153
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
154
+ Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
155
+ return (
156
+ <Button variant={variant} size={size} asChild>
157
+ <AlertDialogPrimitive.Cancel
158
+ data-slot="alert-dialog-cancel"
159
+ className={cn(className)}
160
+ {...props}
161
+ />
162
+ </Button>
163
+ );
164
+ }
165
+
166
+ export {
167
+ AlertDialog,
168
+ AlertDialogAction,
169
+ AlertDialogCancel,
170
+ AlertDialogContent,
171
+ AlertDialogDescription,
172
+ AlertDialogFooter,
173
+ AlertDialogHeader,
174
+ AlertDialogMedia,
175
+ AlertDialogOverlay,
176
+ AlertDialogPortal,
177
+ AlertDialogTitle,
178
+ AlertDialogTrigger,
179
+ };
@@ -0,0 +1,60 @@
1
+ import { cva, type VariantProps } from "class-variance-authority";
2
+ import type * as React from "react";
3
+
4
+ import { cn } from "@/lib/utils";
5
+
6
+ const alertVariants = cva(
7
+ "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-card text-card-foreground",
12
+ destructive:
13
+ "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
14
+ },
15
+ },
16
+ defaultVariants: {
17
+ variant: "default",
18
+ },
19
+ },
20
+ );
21
+
22
+ function Alert({
23
+ className,
24
+ variant,
25
+ ...props
26
+ }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
27
+ return (
28
+ <div
29
+ data-slot="alert"
30
+ role="alert"
31
+ className={cn(alertVariants({ variant }), className)}
32
+ {...props}
33
+ />
34
+ );
35
+ }
36
+
37
+ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
38
+ return (
39
+ <div
40
+ data-slot="alert-title"
41
+ className={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
42
+ {...props}
43
+ />
44
+ );
45
+ }
46
+
47
+ function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
48
+ return (
49
+ <div
50
+ data-slot="alert-description"
51
+ className={cn(
52
+ "col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
53
+ className,
54
+ )}
55
+ {...props}
56
+ />
57
+ );
58
+ }
59
+
60
+ export { Alert, AlertDescription, AlertTitle };
@@ -0,0 +1,46 @@
1
+ import { cva, type VariantProps } from "class-variance-authority";
2
+ import { Slot } from "radix-ui";
3
+ import type * as React from "react";
4
+
5
+ import { cn } from "@/lib/utils";
6
+
7
+ const badgeVariants = cva(
8
+ "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
13
+ secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
14
+ destructive:
15
+ "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
16
+ outline:
17
+ "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
18
+ ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
19
+ link: "text-primary underline-offset-4 [a&]:hover:underline",
20
+ },
21
+ },
22
+ defaultVariants: {
23
+ variant: "default",
24
+ },
25
+ },
26
+ );
27
+
28
+ function Badge({
29
+ className,
30
+ variant = "default",
31
+ asChild = false,
32
+ ...props
33
+ }: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
34
+ const Comp = asChild ? Slot.Root : "span";
35
+
36
+ return (
37
+ <Comp
38
+ data-slot="badge"
39
+ data-variant={variant}
40
+ className={cn(badgeVariants({ variant }), className)}
41
+ {...props}
42
+ />
43
+ );
44
+ }
45
+
46
+ export { Badge, badgeVariants };