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,1085 @@
1
+ /**
2
+ * Kartu Interactive_Prompt (Requirement 8.4, 8.5).
3
+ *
4
+ * - Tipe `"confirmation"`: tombol aksi cepat Approve, Always allow, Deny, dan
5
+ * Cancel. "Always allow" mengirim `always` — opencode mengingat pola yang
6
+ * disetujui sehingga request identik berikutnya tidak menampilkan kartu
7
+ * lagi. "Cancel" memperlakukan sebagai respon Deny yang sama (Requirement
8
+ * 8.5 — meneruskan mengikuti mekanisme penyelesaian Requirement 6).
9
+ * - Tipe `"menu"` (question): baris opsi ala TUI opencode — label beserta
10
+ * keterangan (`description` dari skema tool `question`). Single choice
11
+ * langsung menyelesaikan kartu saat baris diklik (`{ option }`); multi-
12
+ * select (`multiple: true`) memakai checkbox + footer Dismiss/Submit dan
13
+ * mengirim seluruh label terpilih sebagai `{ options: string[] }`. Jawaban
14
+ * bebas (`custom`) tetap tersedia di kedua mode.
15
+ * - Multi-question (`questions` berisi >1 pertanyaan): SEMUA pertanyaan
16
+ * dirender sebagai bagian dalam satu kartu; jawaban dikumpulkan per
17
+ * pertanyaan lalu dikirim SEKALIGUS sebagai `{ answers: string[][] }` —
18
+ * satu array label per pertanyaan, urut sesuai daftar. Submit baru aktif
19
+ * setelah tiap pertanyaan punya minimal satu jawaban (opsi dan/atau teks
20
+ * kustom); Dismiss menolak seluruh request.
21
+ * - Beberapa request permission yang identik (opencode memancarkan satu
22
+ * request per tool call) digroup jadi SATU kartu: `prompts` berisi seluruh
23
+ * anggota grup, satu set tombol, dan jawaban diteruskan ke SEMUA id —
24
+ * server meneruskan reply ke tiap request (fan-out).
25
+ *
26
+ * Feedback klik: tombol/baris yang diklik menampilkan spinner dan seluruh
27
+ * aksi terkunci sampai `prompt_resolved` menghapus kartu. Kegagalan (WS
28
+ * putus, server menolak) ditampilkan DI kartu via `errorSignal` — klik yang
29
+ * gagal tidak lagi diam saja sehingga tombol terasa mati.
30
+ *
31
+ * Desain: panel floating di ATAS composer (ala dialog izin Claude/opencode)
32
+ * dengan animasi masuk/keluar (Motion, di `PromptPanel`). Logika pemetaan aksi & pengelompokan prompt dipisah ke
33
+ * `lib/prompts.ts` (fungsi murni, diuji tanpa DOM — unit test 24.6).
34
+ */
35
+ import {
36
+ ArrowUpIcon,
37
+ CheckIcon,
38
+ ChevronLeftIcon,
39
+ ChevronRightIcon,
40
+ HelpCircleIcon,
41
+ ListChecksIcon,
42
+ PencilLineIcon,
43
+ ShieldAlertIcon,
44
+ ShieldCheckIcon,
45
+ } from "lucide-react";
46
+ import * as m from "motion/react-m";
47
+ import { type ReactNode, useEffect, useRef, useState } from "react";
48
+ import { Badge } from "@/components/ui/badge";
49
+ import { Button } from "@/components/ui/button";
50
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
51
+ import { Spinner } from "@/components/ui/spinner";
52
+ import { type PermissionRisk, parsePermissionTitle } from "@/lib/permission-info";
53
+ import { getPromptQuestions } from "@/lib/prompts";
54
+ import { cn } from "@/lib/utils";
55
+ import type { InteractivePrompt, PromptOption, PromptResponse } from "@/types";
56
+
57
+ export interface PromptCardProps {
58
+ /**
59
+ * Grup prompt yang tampil sebagai satu kartu — minimal satu elemen.
60
+ * Anggota identik (kind + title sama): permission bertumpuk dari opencode.
61
+ */
62
+ prompts: InteractivePrompt[];
63
+ /** Dipanggil dengan respon pengguna untuk tiap id anggota (Req 6.3, 6.7). */
64
+ onResolve: (promptId: string, response: PromptResponse) => void;
65
+ /**
66
+ * Error resolusi terbaru dari server (WS `error` berkode PROMPT_*).
67
+ * `null` = tidak ada. Non-null -> tampil sebagai error kartu, lalu
68
+ * dikonsumsi via `onConsumeError` agar tidak muncul lagi di render lain.
69
+ */
70
+ errorSignal?: string | null;
71
+ /** Reset `errorSignal` di pemilik state setelah kartu menampilkannya. */
72
+ onConsumeError?: () => void;
73
+ /**
74
+ * `true` = kartu docked di dasar MENGGANTIKAN composer (question pending):
75
+ * mengisi sisa tinggi kolom dan hanya area jawaban yang scroll — header
76
+ * pertanyaan dan footer aksi (Dismiss/Submit) selalu terlihat. `false` =
77
+ * kartu mengambang di atas composer (ukuran alami, tanpa scroll internal).
78
+ */
79
+ docked?: boolean;
80
+ }
81
+
82
+ export function PromptCard({
83
+ prompts,
84
+ onResolve,
85
+ errorSignal,
86
+ onConsumeError,
87
+ docked = false,
88
+ }: PromptCardProps) {
89
+ const prompt = prompts[0];
90
+ /** Key aksi yang diklik — spinner pada tombol itu, tombol lain terkunci. */
91
+ const [submittingKey, setSubmittingKey] = useState<string | null>(null);
92
+ /** Kunci kirim sinkron — mencegah balasan ganda dari klik beruntun cepat. */
93
+ const sendingRef = useRef(false);
94
+ /** Pesan error klik TERAKHIR — tampil di kartu, bukan di atas chat. */
95
+ const [error, setError] = useState<string | null>(null);
96
+ /** Isi input jawaban bebas (question dengan flag `custom`). */
97
+ const [customValue, setCustomValue] = useState("");
98
+ /** Multi-select: label opsi yang dicentang (urut klik, bukan urutan daftar). */
99
+ const [selectedMulti, setSelectedMulti] = useState<Set<string>>(new Set());
100
+ /**
101
+ * Multi-question: label terpilih per indeks pertanyaan. Pertanyaan non-
102
+ * multiple berperilaku radio — pilihan baru menggantikan yang lama.
103
+ */
104
+ const [multiSel, setMultiSel] = useState<Record<number, Set<string>>>({});
105
+ /** Multi-question: isi input jawaban bebas per indeks pertanyaan. */
106
+ const [customValues, setCustomValues] = useState<Record<number, string>>({});
107
+ /**
108
+ * Multi-question = wizard bertahap: satu pertanyaan per langkah dengan
109
+ * Back / Next, dan Submit di langkah terakhir.
110
+ */
111
+ const [step, setStep] = useState(0);
112
+ /** Timer auto-lanjut setelah memilih opsi single choice di wizard. */
113
+ const advanceTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
114
+
115
+ // Error server (PROMPT_NOT_FOUND / PROMPT_ALREADY_RESOLVED) -> tampil di
116
+ // kartu, lalu sinyal dikonsumsi supaya tidak muncul di kartu lain.
117
+ useEffect(() => {
118
+ if (errorSignal) {
119
+ sendingRef.current = false;
120
+ setSubmittingKey(null);
121
+ setError(errorSignal);
122
+ onConsumeError?.();
123
+ }
124
+ }, [errorSignal, onConsumeError]);
125
+
126
+ // Kartu dipakai ulang untuk grup prompt berbeda (prompt.id berganti) —
127
+ // reset state lokal agar tidak membawa pilihan kartu sebelumnya.
128
+ // prompt?.id SATU-SATUNYA trigger reset — menghapusnya dari deps membuat
129
+ // efek jalan tiap render dan selalu mengosongkan jawaban.
130
+ // biome-ignore lint/correctness/useExhaustiveDependencies: prompt?.id sengaja jadi satu-satunya trigger reset state
131
+ useEffect(() => {
132
+ setSelectedMulti(new Set());
133
+ setCustomValue("");
134
+ setMultiSel({});
135
+ setCustomValues({});
136
+ setStep(0);
137
+ sendingRef.current = false;
138
+ setSubmittingKey(null);
139
+ setError(null);
140
+ return () => clearTimeout(advanceTimer.current);
141
+ }, [prompt?.id]);
142
+
143
+ /**
144
+ * Pintasan keyboard question (desktop): angka 1–9 memilih opsi ke-n.
145
+ * Handler terbaru disimpan di ref agar listener cukup dipasang sekali.
146
+ */
147
+ const keyHandlerRef = useRef<(e: KeyboardEvent) => void>(() => {});
148
+ useEffect(() => {
149
+ const onKey = (e: KeyboardEvent) => keyHandlerRef.current(e);
150
+ window.addEventListener("keydown", onKey);
151
+ return () => window.removeEventListener("keydown", onKey);
152
+ }, []);
153
+
154
+ if (!prompt) return null;
155
+ const isMenu = prompt.type === "menu";
156
+ const isPermission = prompt.kind === "permission";
157
+ // Question multi-select: jawaban dikumpulkan lewat checkbox lalu dikirim
158
+ // sekaligus — bukan satu klik per opsi.
159
+ const isMulti = isMenu && prompt.multiple === true;
160
+ // Multi-question: seluruh pertanyaan request dirender dalam satu kartu.
161
+ const questions = getPromptQuestions(prompt);
162
+ const isMultiQuestion = isMenu && questions.length > 1;
163
+ // Permission identik yang menumpuk di server — beri tahu user bahwa satu
164
+ // klik menjawab semuanya.
165
+ const twinCount = prompts.length;
166
+
167
+ const resolveResponse = (key: string, response: PromptResponse) => {
168
+ // Kunci SINKRON (ref), bukan state: klik beruntun cepat terjadi sebelum
169
+ // React me-render ulang, sehingga `submittingKey` dari closure masih
170
+ // null dan tiap klik lolos -> balasan ganda ke opencode.
171
+ if (sendingRef.current) return;
172
+ sendingRef.current = true;
173
+ setError(null);
174
+ setSubmittingKey(key);
175
+ try {
176
+ for (const p of prompts) onResolve(p.id, response);
177
+ } catch (e) {
178
+ // Gagal kirim (WS putus dsb.) — pesan error di kartu, tombol aktif lagi.
179
+ sendingRef.current = false;
180
+ setSubmittingKey(null);
181
+ setError(e instanceof Error ? e.message : "Failed to send the response.");
182
+ }
183
+ };
184
+
185
+ /** Kirim jawaban bebas dari input kustom (bentuk respon sama dengan opsi). */
186
+ const submitCustom = () => {
187
+ const text = customValue.trim();
188
+ if (text.length === 0) return;
189
+ setCustomValue("");
190
+ resolveResponse("custom", { option: text });
191
+ };
192
+
193
+ /** Toggle satu opsi multi-select (checkbox baris opsi). */
194
+ const toggleMulti = (label: string) => {
195
+ setSelectedMulti((prev) => {
196
+ const next = new Set(prev);
197
+ if (next.has(label)) next.delete(label);
198
+ else next.add(label);
199
+ return next;
200
+ });
201
+ };
202
+
203
+ /**
204
+ * Kirim jawaban multi-select: seluruh opsi tercentang (+ teks kustom bila
205
+ * diisi) sebagai satu array label — bentuk respon `{ options: string[] }`.
206
+ */
207
+ const submitMulti = (extra?: string) => {
208
+ const labels = [...selectedMulti];
209
+ const text = extra?.trim() ?? "";
210
+ if (text.length > 0) labels.push(text);
211
+ if (labels.length === 0) return;
212
+ setCustomValue("");
213
+ resolveResponse("multi-submit", { options: labels });
214
+ };
215
+
216
+ /** Toggle opsi pada pertanyaan ke-`qi` dalam kartu multi-question. */
217
+ const toggleQOption = (qi: number, label: string, allowMulti: boolean) => {
218
+ setMultiSel((prev) => {
219
+ const cur = new Set(prev[qi] ?? []);
220
+ if (allowMulti) {
221
+ if (cur.has(label)) cur.delete(label);
222
+ else cur.add(label);
223
+ } else {
224
+ // Pertanyaan single choice dalam batch: pilihan baru menggantikan.
225
+ cur.clear();
226
+ cur.add(label);
227
+ }
228
+ return { ...prev, [qi]: cur };
229
+ });
230
+ };
231
+
232
+ /**
233
+ * Kirim jawaban multi-question: SATU array label per pertanyaan (urut
234
+ * daftar). Semua pertanyaan wajib terjawab (opsi dan/atau teks kustom) —
235
+ * opencode menuntut jawaban per pertanyaan; menolak sebagian = Dismiss.
236
+ */
237
+ const submitMultiQuestion = () => {
238
+ const answers = questions.map((_, qi) => {
239
+ const labels = [...(multiSel[qi] ?? [])];
240
+ const text = (customValues[qi] ?? "").trim();
241
+ if (text.length > 0) labels.push(text);
242
+ return labels;
243
+ });
244
+ if (answers.some((a) => a.length === 0)) return;
245
+ setCustomValues({});
246
+ resolveResponse("multi-question", { answers });
247
+ };
248
+
249
+ /** Semua pertanyaan multi-question sudah punya minimal satu jawaban. */
250
+ const allQuestionsAnswered = questions.every(
251
+ (_q, qi) => (multiSel[qi]?.size ?? 0) > 0 || (customValues[qi] ?? "").trim().length > 0,
252
+ );
253
+
254
+ /**
255
+ * Baris opsi question (boxed list ala libadwaita):
256
+ * - `action` : single choice — ketuk baris langsung menjawab.
257
+ * - `radio` : single choice dalam multi-question — memilih, dikirim lewat
258
+ * Submit.
259
+ * - `checkbox` : multi-select — centang beberapa, dikirim lewat Submit.
260
+ * Baris minimal 52px di HP (target sentuh nyaman), 44px di desktop.
261
+ */
262
+ const optionRows = (
263
+ opts: PromptOption[],
264
+ optsKey: string,
265
+ mode: "action" | "radio" | "checkbox",
266
+ checked: (label: string) => boolean,
267
+ onToggle: (label: string) => void,
268
+ onPick: (label: string) => void,
269
+ ) =>
270
+ opts.map((opt, i) => {
271
+ const isChecked = mode !== "action" && checked(opt.label);
272
+ const busy = mode === "action" && submittingKey === `option-${opt.label}`;
273
+ const disabled = submittingKey !== null;
274
+ // Pintasan angka hanya untuk daftar opsi utama (bukan multi-question).
275
+ const shortcut =
276
+ (optsKey === "single" || optsKey.startsWith("step")) && i < 9 ? String(i + 1) : undefined;
277
+ return (
278
+ <li key={`${optsKey}-${opt.label}`}>
279
+ <button
280
+ type="button"
281
+ role={mode === "checkbox" ? "checkbox" : mode === "radio" ? "radio" : undefined}
282
+ {...(mode !== "action" ? { "aria-checked": isChecked } : {})}
283
+ aria-busy={busy}
284
+ aria-keyshortcuts={shortcut}
285
+ disabled={disabled}
286
+ onClick={() => (mode === "action" ? onPick(opt.label) : onToggle(opt.label))}
287
+ className={cn(
288
+ // Baris boxed list ala `ActionRow` (settings/prefs.tsx): 56px,
289
+ // px-4, hover/active muted, ring inset. `touch-manipulation`
290
+ // menghapus jeda tap di HP.
291
+ "flex min-h-14 w-full touch-manipulation items-center gap-3 px-4 py-2.5 text-left transition-colors outline-none",
292
+ "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",
293
+ disabled && !busy && "pointer-events-none opacity-50",
294
+ )}
295
+ >
296
+ {mode === "checkbox" && (
297
+ <span
298
+ aria-hidden
299
+ className={cn(
300
+ "flex size-5 shrink-0 items-center justify-center rounded-md border-2 transition-colors sm:size-4.5",
301
+ isChecked
302
+ ? "border-primary bg-primary text-primary-foreground"
303
+ : "border-muted-foreground/45",
304
+ )}
305
+ >
306
+ {isChecked && <CheckIcon className="size-3" strokeWidth={3} />}
307
+ </span>
308
+ )}
309
+ {mode === "radio" && (
310
+ <span
311
+ aria-hidden
312
+ className={cn(
313
+ "flex size-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors sm:size-4.5",
314
+ isChecked ? "border-primary bg-primary" : "border-muted-foreground/45",
315
+ )}
316
+ >
317
+ {isChecked && <span className="size-1.5 rounded-full bg-primary-foreground" />}
318
+ </span>
319
+ )}
320
+ <span className="flex min-w-0 flex-1 flex-col gap-0.5 py-0.5">
321
+ <span className="wrap-anywhere text-[15px] leading-5">{opt.label}</span>
322
+ {opt.description && (
323
+ <span className="wrap-anywhere text-[13px] leading-snug text-muted-foreground">
324
+ {opt.description}
325
+ </span>
326
+ )}
327
+ </span>
328
+ {/* Suffix ala AdwActionRow: chevron untuk baris yang langsung
329
+ menjawab; spinner saat terkirim. Pintasan angka sebagai
330
+ keycap kecil, hanya di desktop. */}
331
+ {mode === "action" && (
332
+ <span className="flex shrink-0 items-center gap-2">
333
+ {shortcut && (
334
+ <kbd
335
+ aria-hidden
336
+ className="hidden rounded border px-1.5 font-mono text-[11px] leading-5 text-muted-foreground sm:inline"
337
+ >
338
+ {shortcut}
339
+ </kbd>
340
+ )}
341
+ {busy ? (
342
+ <Spinner className="size-4" />
343
+ ) : (
344
+ <ChevronRightIcon aria-hidden className="size-4 text-muted-foreground" />
345
+ )}
346
+ </span>
347
+ )}
348
+ </button>
349
+ </li>
350
+ );
351
+ });
352
+
353
+ /**
354
+ * Input jawaban bebas. Enter mengirim sesuai mode kartu. `showSend: false`
355
+ * = jawaban ikut terkirim lewat Submit di footer (multi-question /
356
+ * multi-select), jadi tombol kirim per baris disembunyikan.
357
+ * Font 16px di HP: iOS Safari tidak men-zoom halaman saat input difokus.
358
+ */
359
+ const customInput = (opts: {
360
+ value: string;
361
+ onChange: (v: string) => void;
362
+ onSubmit: () => void;
363
+ sendDisabled: boolean;
364
+ busy: boolean;
365
+ ariaLabel?: string;
366
+ showSend?: boolean;
367
+ enterHint?: "send" | "next";
368
+ }) => (
369
+ // Baris ala `EntryRow` (settings/prefs.tsx): tinggal di DALAM boxed list
370
+ // opsi sebagai baris terakhir, label kecil di atas input; tombol kirim
371
+ // bulat muncul begitu ada isi.
372
+ <li>
373
+ <label className="flex min-h-14 cursor-text items-center gap-3 px-4 py-2 focus-within:bg-muted/30">
374
+ <PencilLineIcon aria-hidden className="size-4 shrink-0 text-muted-foreground" />
375
+ <span className="flex min-w-0 flex-1 flex-col">
376
+ <span className="text-[12px] leading-4 text-muted-foreground">Other</span>
377
+ <input
378
+ value={opts.value}
379
+ onChange={(e) => opts.onChange(e.target.value)}
380
+ onKeyDown={(e) => {
381
+ if (e.key === "Enter" && !e.nativeEvent.isComposing) {
382
+ e.preventDefault();
383
+ opts.onSubmit();
384
+ }
385
+ }}
386
+ placeholder="Type your own answer…"
387
+ disabled={submittingKey !== null}
388
+ aria-label={opts.ariaLabel ?? "Your own answer"}
389
+ enterKeyHint={opts.enterHint ?? (opts.showSend === false ? "next" : "send")}
390
+ // 16px di HP: iOS Safari tidak men-zoom saat input difokus.
391
+ className="h-7 w-full min-w-0 bg-transparent text-base outline-none placeholder:text-muted-foreground/70 disabled:opacity-60 sm:text-[15px]"
392
+ />
393
+ </span>
394
+ {opts.showSend !== false && (
395
+ <button
396
+ type="button"
397
+ disabled={submittingKey !== null || opts.sendDisabled}
398
+ aria-busy={opts.busy}
399
+ aria-label="Send your answer"
400
+ onClick={opts.onSubmit}
401
+ className={cn(
402
+ "flex size-9 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-0 sm:size-8",
403
+ opts.busy && "disabled:opacity-60",
404
+ )}
405
+ >
406
+ {opts.busy ? <Spinner className="size-4" /> : <ArrowUpIcon className="size-4" />}
407
+ </button>
408
+ )}
409
+ </label>
410
+ </li>
411
+ );
412
+
413
+ // Izin tool punya kartu sendiri: penjelasan ramah + aksi yang jelas
414
+ // konsekuensinya (lihat `PermissionCard`).
415
+ if (isPermission) {
416
+ return (
417
+ <PermissionCard
418
+ prompt={prompt}
419
+ twinCount={twinCount}
420
+ submittingKey={submittingKey}
421
+ error={error}
422
+ onResolve={resolveResponse}
423
+ />
424
+ );
425
+ }
426
+
427
+ // ---- Question ----------------------------------------------------------
428
+ /** Pertanyaan ke-`qi` sudah punya jawaban (opsi dan/atau teks kustom). */
429
+ const isAnswered = (qi: number) =>
430
+ (multiSel[qi]?.size ?? 0) > 0 || (customValues[qi] ?? "").trim().length > 0;
431
+ const answeredCount = questions.filter((_q, qi) => isAnswered(qi)).length;
432
+
433
+ // Wizard multi-question: indeks dijaga dalam rentang (daftar bisa berubah).
434
+ const stepIdx = Math.min(step, Math.max(questions.length - 1, 0));
435
+ const isLastStep = stepIdx === questions.length - 1;
436
+ const goTo = (next: number) => {
437
+ clearTimeout(advanceTimer.current);
438
+ if (next < 0 || next >= questions.length || next === stepIdx) return;
439
+ setStep(next);
440
+ };
441
+
442
+ /**
443
+ * Pilih opsi di wizard. Pertanyaan single choice otomatis lanjut ke
444
+ * langkah berikutnya setelah jeda singkat (pilihan sempat terlihat) —
445
+ * kecuali di langkah terakhir, di mana user menekan Submit.
446
+ */
447
+ const pickStepOption = (qi: number, label: string, allowMulti: boolean) => {
448
+ toggleQOption(qi, label, allowMulti);
449
+ if (allowMulti || qi >= questions.length - 1) return;
450
+ clearTimeout(advanceTimer.current);
451
+ advanceTimer.current = setTimeout(() => goTo(qi + 1), 220);
452
+ };
453
+
454
+ // Aksi footer: Submit hanya untuk mode yang MENGUMPULKAN jawaban (multi-
455
+ // select / multi-question). Single choice tanpa Submit — ketuk opsi
456
+ // langsung menjawab.
457
+ const footer = isMulti
458
+ ? {
459
+ submitDisabled: selectedMulti.size === 0 && customValue.trim().length === 0,
460
+ busy: submittingKey === "multi-submit",
461
+ onSubmit: () => submitMulti(customValue),
462
+ }
463
+ : null;
464
+
465
+ const hint = isMulti ? "Select all that apply" : "Tap an option to answer";
466
+
467
+ // Pintasan keyboard (desktop), diabaikan saat mengetik / ada modifier:
468
+ // - Angka 1–9 = pilih opsi ke-n (single: jawab / lanjut; multi: toggle).
469
+ // - Wizard: ← / → = langkah sebelumnya / berikutnya.
470
+ keyHandlerRef.current = (e) => {
471
+ if (submittingKey !== null || e.defaultPrevented || e.isComposing) return;
472
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
473
+ const el = e.target as HTMLElement | null;
474
+ if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) return;
475
+ if (isMultiQuestion) {
476
+ if (e.key === "ArrowLeft") {
477
+ e.preventDefault();
478
+ goTo(stepIdx - 1);
479
+ return;
480
+ }
481
+ if (e.key === "ArrowRight") {
482
+ e.preventDefault();
483
+ if (isAnswered(stepIdx)) goTo(stepIdx + 1);
484
+ return;
485
+ }
486
+ }
487
+ if (!/^[1-9]$/.test(e.key)) return;
488
+ const n = Number(e.key) - 1;
489
+ if (isMultiQuestion) {
490
+ const q = questions[stepIdx];
491
+ const opt = q?.options?.[n];
492
+ if (!q || !opt) return;
493
+ e.preventDefault();
494
+ pickStepOption(stepIdx, opt.label, q.multiple === true);
495
+ return;
496
+ }
497
+ const opt = (prompt.options ?? [])[n];
498
+ if (!opt) return;
499
+ e.preventDefault();
500
+ if (isMulti) toggleMulti(opt.label);
501
+ else resolveResponse(`option-${opt.label}`, { option: opt.label });
502
+ };
503
+
504
+ const optionCount = isMultiQuestion
505
+ ? (questions[stepIdx]?.options?.length ?? 0)
506
+ : (prompt.options?.length ?? 0);
507
+
508
+ return (
509
+ <Card
510
+ role="dialog"
511
+ aria-labelledby={`q-${prompt.id}-title`}
512
+ className={cn(
513
+ // Permukaan ala AdwDialog: latar "window" (bg-background) supaya boxed
514
+ // list (bg-card) di dalamnya terbaca sebagai grup — sama seperti
515
+ // halaman Settings. Tanpa ring/blur.
516
+ "gap-0 rounded-2xl border bg-background py-0 shadow-lg",
517
+ // Docked (menggantikan composer): kartu mengisi sisa kolom; hanya
518
+ // daftar jawaban yang scroll, judul & footer tetap terlihat.
519
+ docked && "flex min-h-0 shrink flex-col overflow-hidden",
520
+ )}
521
+ >
522
+ {/* Header: judul = PERTANYAAN itu sendiri (bukan label "Question").
523
+ Pertanyaan sangat panjang dibatasi tingginya (scroll sendiri) agar
524
+ opsi tetap kelihatan di layar HP. */}
525
+ <CardHeader className="flex max-h-[30dvh] shrink-0 flex-row items-start gap-3 overflow-y-auto px-5 pt-4 pb-3">
526
+ <span
527
+ aria-hidden
528
+ className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-foreground/80"
529
+ >
530
+ {isMultiQuestion || isMulti ? (
531
+ <ListChecksIcon className="size-4" />
532
+ ) : (
533
+ <HelpCircleIcon className="size-4" />
534
+ )}
535
+ </span>
536
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
537
+ <CardTitle
538
+ id={`q-${prompt.id}-title`}
539
+ className="wrap-anywhere text-[17px] leading-6 font-semibold tracking-tight sm:text-base"
540
+ >
541
+ {isMultiQuestion
542
+ ? `The agent has ${questions.length} questions`
543
+ : (prompt.title ?? "The agent has a question")}
544
+ </CardTitle>
545
+ <CardDescription className="flex items-center gap-1.5 text-[13px]">
546
+ {submittingKey !== null ? (
547
+ <>
548
+ <Spinner className="size-3" />
549
+ Sending…
550
+ </>
551
+ ) : isMultiQuestion ? (
552
+ `Question ${stepIdx + 1} of ${questions.length} · ${answeredCount} answered`
553
+ ) : optionCount > 0 ? (
554
+ <>
555
+ {/* HP: petunjuk sentuh. Desktop: pintasan angka. */}
556
+ <span className="sm:hidden">{hint}</span>
557
+ <span className="hidden sm:inline">
558
+ {isMulti ? hint : "Click an option"} · press{" "}
559
+ <kbd className="rounded border px-1 font-mono text-[10px]">1</kbd>
560
+ {optionCount > 1 && (
561
+ <>
562
+ –
563
+ <kbd className="rounded border px-1 font-mono text-[10px]">
564
+ {Math.min(optionCount, 9)}
565
+ </kbd>
566
+ </>
567
+ )}
568
+ </span>
569
+ </>
570
+ ) : (
571
+ hint
572
+ )}
573
+ </CardDescription>
574
+ </div>
575
+ </CardHeader>
576
+
577
+ {isMultiQuestion && (
578
+ <StepIndicator
579
+ count={questions.length}
580
+ current={stepIdx}
581
+ answered={isAnswered}
582
+ labels={questions.map((q, qi) => q.header ?? `Question ${qi + 1}`)}
583
+ disabled={submittingKey !== null}
584
+ onSelect={goTo}
585
+ />
586
+ )}
587
+
588
+ <CardContent
589
+ className={cn(
590
+ "flex flex-col px-3 sm:px-4",
591
+ "pt-1",
592
+ docked ? "min-h-0 flex-auto overflow-hidden" : "pb-3",
593
+ )}
594
+ >
595
+ {/* biome-ignore lint/a11y/useSemanticElements: wrapper flex/scroll kartu; fieldset mengubah layout & styling */}
596
+ <div
597
+ role="group"
598
+ aria-label="Answers"
599
+ className={cn(
600
+ "flex flex-col",
601
+ docked &&
602
+ "min-h-0 flex-auto overflow-x-hidden overflow-y-auto overscroll-contain pb-3 [scrollbar-width:thin] [-webkit-overflow-scrolling:touch]",
603
+ )}
604
+ >
605
+ {isMultiQuestion ? (
606
+ // Semua langkah ditumpuk di SATU sel grid: tinggi kartu = langkah
607
+ // tertinggi dan TETAP selama navigasi. Sebelumnya hanya langkah
608
+ // aktif yang dirender, sehingga tinggi kartu berubah tiap ganti
609
+ // langkah — dan karena kartu docked di bawah timeline (flex),
610
+ // timeline ikut membesar/mengecil lalu scroller menyesuaikan
611
+ // posisi scroll: terlihat seperti kartu melebar lalu menyusut.
612
+ // Langkah non-aktif tetap ada di layout tapi `invisible` + `inert`
613
+ // (tidak terlihat, tidak bisa difokus, tidak dibaca screen reader).
614
+ // `overflow-x-clip`: langkah non-aktif digeser ±24px (animasi
615
+ // slide) sehingga melewati tepi grid. Tanpa clip, area scroll
616
+ // kartu ikut bisa digeser horizontal. `clip` (bukan `hidden`)
617
+ // tidak membuat scroll container baru, jadi scroll vertikal
618
+ // tetap ditangani wadah jawaban.
619
+ <div className="grid min-w-0 overflow-x-clip">
620
+ {questions.map((q, qi) => {
621
+ const active = qi === stepIdx;
622
+ const qMulti = q.multiple === true;
623
+ const sel = multiSel[qi] ?? new Set<string>();
624
+ // Geser horizontal sesuai posisi relatif terhadap langkah aktif
625
+ // (dimatikan bila prefers-reduced-motion — MotionConfig di App).
626
+ const offset = qi < stepIdx ? -24 : qi > stepIdx ? 24 : 0;
627
+ return (
628
+ <m.section
629
+ // biome-ignore lint/suspicious/noArrayIndexKey: daftar pertanyaan statis, state diindeks per qi
630
+ key={`step-${qi}`}
631
+ initial={false}
632
+ animate={{
633
+ opacity: active ? 1 : 0,
634
+ x: offset,
635
+ transition: { duration: active ? 0.2 : 0.12, ease: "easeOut" },
636
+ }}
637
+ inert={!active}
638
+ aria-hidden={!active}
639
+ aria-labelledby={`q-${prompt.id}-${qi}`}
640
+ className={cn(
641
+ "col-start-1 row-start-1 flex min-w-0 flex-col gap-2.5",
642
+ !active && "pointer-events-none",
643
+ )}
644
+ >
645
+ {/* Judul grup ala `PrefsGroup`: header kecil, pertanyaan
646
+ sebagai judul, petunjuk sebagai deskripsi. */}
647
+ <div className="flex flex-col gap-0.5 px-1">
648
+ {q.header && (
649
+ <span className="text-[12px] text-muted-foreground">{q.header}</span>
650
+ )}
651
+ <h3
652
+ id={`q-${prompt.id}-${qi}`}
653
+ className="wrap-anywhere text-[15px] leading-5 font-semibold tracking-tight"
654
+ >
655
+ {q.question ?? `Question ${qi + 1}`}
656
+ </h3>
657
+ {qMulti && (
658
+ <p className="text-[13px] text-muted-foreground">Select all that apply</p>
659
+ )}
660
+ </div>
661
+ <OptionList>
662
+ {optionRows(
663
+ q.options ?? [],
664
+ // Pintasan angka hanya untuk langkah aktif.
665
+ active ? `step${qi}` : `hidden${qi}`,
666
+ qMulti ? "checkbox" : "radio",
667
+ (label) => sel.has(label),
668
+ (label) => pickStepOption(qi, label, qMulti),
669
+ () => {},
670
+ )}
671
+ {q.custom !== false &&
672
+ customInput({
673
+ value: customValues[qi] ?? "",
674
+ onChange: (v) => setCustomValues((prev) => ({ ...prev, [qi]: v })),
675
+ // Enter: lanjut ke langkah berikutnya, atau kirim
676
+ // di langkah terakhir.
677
+ onSubmit: () =>
678
+ qi === questions.length - 1
679
+ ? submitMultiQuestion()
680
+ : isAnswered(qi) && goTo(qi + 1),
681
+ sendDisabled: true,
682
+ busy: false,
683
+ ariaLabel: `Your own answer for question ${qi + 1}`,
684
+ showSend: false,
685
+ enterHint: qi === questions.length - 1 ? "send" : "next",
686
+ })}
687
+ </OptionList>
688
+ </m.section>
689
+ );
690
+ })}
691
+ </div>
692
+ ) : (
693
+ <OptionList>
694
+ {optionRows(
695
+ prompt.options ?? [],
696
+ "single",
697
+ isMulti ? "checkbox" : "action",
698
+ (label) => selectedMulti.has(label),
699
+ toggleMulti,
700
+ (label) => resolveResponse(`option-${label}`, { option: label }),
701
+ )}
702
+ {prompt.custom === true &&
703
+ customInput({
704
+ value: customValue,
705
+ onChange: setCustomValue,
706
+ onSubmit: () => (isMulti ? submitMulti(customValue) : submitCustom()),
707
+ sendDisabled: isMulti
708
+ ? customValue.trim().length === 0 && selectedMulti.size === 0
709
+ : customValue.trim().length === 0,
710
+ busy: submittingKey === "custom" || submittingKey === "multi-submit",
711
+ // Multi-select: teks ikut terkirim lewat Submit di footer.
712
+ showSend: !isMulti,
713
+ })}
714
+ </OptionList>
715
+ )}
716
+ </div>
717
+ {error && (
718
+ <p className="pt-2 text-[13px] text-destructive sm:text-xs" role="alert">
719
+ {error}
720
+ </p>
721
+ )}
722
+ </CardContent>
723
+
724
+ {/* Footer selalu di luar area scroll.
725
+ - Wizard: Skip | Back · Next/Submit answers.
726
+ - Multi-select: Skip | Submit.
727
+ - Single choice: hanya Skip (ketuk opsi langsung menjawab). */}
728
+ <div
729
+ className={cn(
730
+ "flex shrink-0 items-center gap-2 px-3 pt-1 pb-3 sm:px-4",
731
+ footer === null && !isMultiQuestion && "justify-end",
732
+ )}
733
+ >
734
+ <Button
735
+ type="button"
736
+ variant={isMultiQuestion ? "ghost" : "secondary"}
737
+ disabled={submittingKey !== null}
738
+ aria-busy={submittingKey === "dismiss"}
739
+ onClick={() => resolveResponse("dismiss", "cancel")}
740
+ className={cn(
741
+ "h-11 rounded-full px-5 text-[15px] sm:h-9 sm:text-sm",
742
+ isMultiQuestion && "px-3 text-muted-foreground hover:text-foreground sm:px-4",
743
+ footer !== null && "flex-1 sm:flex-none",
744
+ )}
745
+ >
746
+ {submittingKey === "dismiss" && <Spinner className="size-4" data-icon="inline-start" />}
747
+ Skip
748
+ </Button>
749
+ {isMultiQuestion && (
750
+ <div className="ms-auto flex items-center gap-2">
751
+ <Button
752
+ type="button"
753
+ variant="secondary"
754
+ disabled={submittingKey !== null || stepIdx === 0}
755
+ onClick={() => goTo(stepIdx - 1)}
756
+ aria-label="Previous question"
757
+ aria-keyshortcuts="ArrowLeft"
758
+ className="h-11 rounded-full px-4 text-[15px] sm:h-9 sm:text-sm"
759
+ >
760
+ <ChevronLeftIcon className="size-4" data-icon="inline-start" />
761
+ <span className="hidden sm:inline">Back</span>
762
+ </Button>
763
+ {isLastStep ? (
764
+ <Button
765
+ type="button"
766
+ disabled={submittingKey !== null || !allQuestionsAnswered}
767
+ aria-busy={submittingKey === "multi-question"}
768
+ onClick={submitMultiQuestion}
769
+ className="h-11 rounded-full px-5 text-[15px] sm:h-9 sm:text-sm"
770
+ >
771
+ {submittingKey === "multi-question" && (
772
+ <Spinner className="size-4" data-icon="inline-start" />
773
+ )}
774
+ Submit answers
775
+ </Button>
776
+ ) : (
777
+ <Button
778
+ type="button"
779
+ disabled={submittingKey !== null || !isAnswered(stepIdx)}
780
+ onClick={() => goTo(stepIdx + 1)}
781
+ aria-keyshortcuts="ArrowRight"
782
+ className="h-11 rounded-full px-5 text-[15px] sm:h-9 sm:text-sm"
783
+ >
784
+ Next
785
+ <ChevronRightIcon className="size-4" data-icon="inline-end" />
786
+ </Button>
787
+ )}
788
+ </div>
789
+ )}
790
+ {footer && (
791
+ <Button
792
+ type="button"
793
+ disabled={submittingKey !== null || footer.submitDisabled}
794
+ aria-busy={footer.busy}
795
+ onClick={footer.onSubmit}
796
+ className="h-11 flex-[2] rounded-full px-5 text-[15px] sm:ms-auto sm:h-9 sm:flex-none sm:text-sm"
797
+ >
798
+ {footer.busy && <Spinner className="size-4" data-icon="inline-start" />}
799
+ Submit
800
+ </Button>
801
+ )}
802
+ </div>
803
+ </Card>
804
+ );
805
+ }
806
+
807
+ /**
808
+ * Penanda langkah wizard: satu segmen per pertanyaan. Segmen terisi =
809
+ * sudah dijawab, segmen aktif lebih tebal. Tiap segmen bisa diketuk untuk
810
+ * lompat ke pertanyaan itu (target sentuh 44px, garisnya tetap tipis).
811
+ */
812
+ function StepIndicator({
813
+ count,
814
+ current,
815
+ answered,
816
+ labels,
817
+ disabled,
818
+ onSelect,
819
+ }: {
820
+ count: number;
821
+ current: number;
822
+ answered: (i: number) => boolean;
823
+ labels: string[];
824
+ disabled: boolean;
825
+ onSelect: (i: number) => void;
826
+ }) {
827
+ return (
828
+ <nav aria-label="Questions" className="shrink-0 px-4 sm:px-5">
829
+ <ol className="-my-2 flex gap-1.5">
830
+ {Array.from({ length: count }, (_, i) => {
831
+ const done = answered(i);
832
+ const active = i === current;
833
+ return (
834
+ // biome-ignore lint/suspicious/noArrayIndexKey: segmen statis per indeks pertanyaan
835
+ <li key={i} className="min-w-0 flex-1">
836
+ <button
837
+ type="button"
838
+ disabled={disabled}
839
+ onClick={() => onSelect(i)}
840
+ aria-current={active ? "step" : undefined}
841
+ aria-label={`${labels[i]}${done ? " (answered)" : ""}`}
842
+ className="group flex h-11 w-full touch-manipulation items-center outline-none sm:h-6"
843
+ >
844
+ <span
845
+ className={cn(
846
+ "h-1 w-full rounded-full transition-colors group-focus-visible:ring-[3px] group-focus-visible:ring-ring/50",
847
+ active
848
+ ? "bg-primary"
849
+ : done
850
+ ? "bg-primary/45 group-hover:bg-primary/60"
851
+ : "bg-muted group-hover:bg-muted-foreground/30",
852
+ )}
853
+ />
854
+ </button>
855
+ </li>
856
+ );
857
+ })}
858
+ </ol>
859
+ </nav>
860
+ );
861
+ }
862
+
863
+ /** Daftar opsi bergaya boxed list (kotak membulat, baris berpemisah). */
864
+ function OptionList({ children }: { children: ReactNode }) {
865
+ // Sama dengan isi `PrefsGroup` (settings/prefs.tsx): kartu membulat,
866
+ // baris berpemisah, latar card.
867
+ return (
868
+ <div className="overflow-hidden rounded-xl border bg-card shadow-xs">
869
+ <ul className="flex flex-col divide-y">{children}</ul>
870
+ </div>
871
+ );
872
+ }
873
+
874
+ // -------------------------------------------------------- permission ----
875
+
876
+ /**
877
+ * Tingkat risiko hanya diwakili ikon + label teks (bukan garis/warna
878
+ * dekoratif) — tetap sederhana ala libadwaita, dan tidak bergantung pada
879
+ * warna saja.
880
+ */
881
+ const RISK_STYLE: Record<PermissionRisk, { icon: string; label: string }> = {
882
+ high: { icon: "bg-amber-500/15 text-amber-600 dark:text-amber-400", label: "Review carefully" },
883
+ medium: { icon: "bg-muted text-foreground/80", label: "" },
884
+ low: { icon: "bg-muted text-muted-foreground", label: "Read-only" },
885
+ };
886
+
887
+ /**
888
+ * Kartu izin tool.
889
+ *
890
+ * - Judul = KALIMAT apa yang diminta ("Access a folder outside the
891
+ * project"), nama permission internal jadi label kecil.
892
+ * - Target (path/perintah) tampil sebagai daftar monospace yang terbaca.
893
+ * - Aksi menurut konsekuensi: "Allow once" (utama) & "Deny"; "Always allow"
894
+ * terpisah karena efeknya permanen untuk sesi ini. "Cancel" dihapus —
895
+ * identik dengan Deny (Req 8.5).
896
+ * - Pintasan keyboard: Enter = izinkan sekali, Esc = tolak.
897
+ *
898
+ * Responsif:
899
+ * - Desktop: satu baris aksi ringkas, tombol kecil + petunjuk pintasan.
900
+ * - HP: teks lebih besar & tombol setinggi target sentuh (44px+), disusun
901
+ * vertikal selebar kartu dengan aksi utama di ATAS (paling dekat ibu jari
902
+ * saat kartu di atas composer).
903
+ */
904
+ function PermissionCard({
905
+ prompt,
906
+ twinCount,
907
+ submittingKey,
908
+ error,
909
+ onResolve,
910
+ }: {
911
+ prompt: InteractivePrompt;
912
+ twinCount: number;
913
+ submittingKey: string | null;
914
+ error: string | null;
915
+ onResolve: (key: string, response: PromptResponse) => void;
916
+ }) {
917
+ const info = parsePermissionTitle(prompt.title);
918
+ const style = RISK_STYLE[info.risk];
919
+ const busy = submittingKey !== null;
920
+
921
+ // Enter = allow once, Esc = deny — hanya saat fokus TIDAK di kolom isian
922
+ // (composer / input lain), agar mengetik tidak menyetujui izin tanpa sengaja.
923
+ useEffect(() => {
924
+ const onKey = (e: KeyboardEvent) => {
925
+ if (busy || e.defaultPrevented || e.isComposing) return;
926
+ const el = e.target as HTMLElement | null;
927
+ const typing =
928
+ el !== null &&
929
+ (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
930
+ if (typing) return;
931
+ if (e.key === "Enter" && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
932
+ e.preventDefault();
933
+ onResolve("approve", "approve");
934
+ } else if (e.key === "Escape") {
935
+ e.preventDefault();
936
+ onResolve("deny", "deny");
937
+ }
938
+ };
939
+ window.addEventListener("keydown", onKey);
940
+ return () => window.removeEventListener("keydown", onKey);
941
+ }, [busy, onResolve]);
942
+
943
+ const spin = (key: string) =>
944
+ submittingKey === key ? <Spinner className="size-4" data-icon="inline-start" /> : null;
945
+
946
+ /** Tombol: tinggi sentuh di HP, ringkas di desktop. */
947
+ const btn = "h-11 w-full text-[15px] sm:h-8 sm:w-auto sm:text-sm";
948
+
949
+ return (
950
+ <Card
951
+ role="alertdialog"
952
+ aria-labelledby={`perm-${prompt.id}-title`}
953
+ aria-describedby={`perm-${prompt.id}-targets`}
954
+ className="gap-4 rounded-2xl border bg-card py-4 shadow-lg sm:gap-3 sm:rounded-xl sm:py-3.5"
955
+ >
956
+ <CardHeader className="flex flex-row items-start gap-3 px-4">
957
+ <span
958
+ aria-hidden
959
+ className={cn(
960
+ "flex size-10 shrink-0 items-center justify-center rounded-full sm:size-8",
961
+ style.icon,
962
+ )}
963
+ >
964
+ <ShieldAlertIcon className="size-5 sm:size-4.5" />
965
+ </span>
966
+ <div className="flex min-w-0 flex-1 flex-col gap-1 sm:gap-0.5">
967
+ <CardTitle
968
+ id={`perm-${prompt.id}-title`}
969
+ className="flex flex-wrap items-center gap-x-2 gap-y-1 text-base leading-6 sm:text-sm sm:leading-5"
970
+ >
971
+ <span>{info.label}</span>
972
+ {twinCount > 1 && (
973
+ <Badge variant="secondary" title={`${twinCount} identical requests`}>
974
+ ×{twinCount}
975
+ </Badge>
976
+ )}
977
+ </CardTitle>
978
+ <CardDescription className="flex flex-wrap items-center gap-x-1.5 text-[13px] sm:text-xs">
979
+ <span>The agent is asking for permission</span>
980
+ {info.permission && (
981
+ <>
982
+ <span aria-hidden>·</span>
983
+ <code className="font-mono text-[12px] sm:text-[11px]">{info.permission}</code>
984
+ </>
985
+ )}
986
+ {style.label && (
987
+ <>
988
+ <span aria-hidden>·</span>
989
+ <span
990
+ className={cn(
991
+ info.risk === "high" && "font-medium text-amber-700 dark:text-amber-400",
992
+ )}
993
+ >
994
+ {style.label}
995
+ </span>
996
+ </>
997
+ )}
998
+ </CardDescription>
999
+ </div>
1000
+ </CardHeader>
1001
+
1002
+ <CardContent className="flex flex-col gap-4 px-4 sm:gap-3">
1003
+ {info.targets.length > 0 && (
1004
+ <ul
1005
+ id={`perm-${prompt.id}-targets`}
1006
+ className="flex max-h-40 flex-col gap-1 overflow-y-auto rounded-xl bg-muted/60 px-3 py-2.5 [scrollbar-width:thin] sm:max-h-32 sm:rounded-lg sm:py-2"
1007
+ >
1008
+ {info.targets.map((t) => (
1009
+ // wrap-anywhere: path panjang tanpa spasi tidak meluapkan kartu.
1010
+ <li
1011
+ key={t}
1012
+ className="wrap-anywhere font-mono text-[13px] leading-relaxed text-foreground/90 sm:text-xs"
1013
+ >
1014
+ {t}
1015
+ </li>
1016
+ ))}
1017
+ </ul>
1018
+ )}
1019
+
1020
+ {/* HP: tombol penuh lebar bertumpuk, aksi utama di atas. Desktop:
1021
+ satu baris — "Always allow" di kiri, Deny & Allow once di kanan. */}
1022
+ <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
1023
+ <Button
1024
+ type="button"
1025
+ size="sm"
1026
+ disabled={busy}
1027
+ aria-busy={submittingKey === "approve"}
1028
+ onClick={() => onResolve("approve", "approve")}
1029
+ aria-keyshortcuts="Enter"
1030
+ className={cn(btn, "sm:order-3")}
1031
+ >
1032
+ {spin("approve")}
1033
+ Allow once
1034
+ <Kbd>↵</Kbd>
1035
+ </Button>
1036
+ <Button
1037
+ type="button"
1038
+ size="sm"
1039
+ variant="outline"
1040
+ disabled={busy}
1041
+ aria-busy={submittingKey === "deny"}
1042
+ onClick={() => onResolve("deny", "deny")}
1043
+ aria-keyshortcuts="Escape"
1044
+ className={cn(btn, "sm:order-2 sm:ms-auto")}
1045
+ >
1046
+ {spin("deny")}
1047
+ Deny
1048
+ <Kbd>Esc</Kbd>
1049
+ </Button>
1050
+ <Button
1051
+ type="button"
1052
+ size="sm"
1053
+ variant="ghost"
1054
+ disabled={busy}
1055
+ aria-busy={submittingKey === "always"}
1056
+ onClick={() => onResolve("always", "always")}
1057
+ title="Don't ask again for matching requests in this session"
1058
+ className={cn(btn, "text-muted-foreground hover:text-foreground sm:order-1")}
1059
+ >
1060
+ {spin("always") ?? <ShieldCheckIcon className="size-4" data-icon="inline-start" />}
1061
+ Always allow
1062
+ </Button>
1063
+ </div>
1064
+
1065
+ {error && (
1066
+ <p className="text-[13px] text-destructive sm:text-xs" role="alert">
1067
+ {error}
1068
+ </p>
1069
+ )}
1070
+ </CardContent>
1071
+ </Card>
1072
+ );
1073
+ }
1074
+
1075
+ /** Petunjuk pintasan kecil di dalam tombol (disembunyikan di layar sentuh). */
1076
+ function Kbd({ children }: { children: string }) {
1077
+ return (
1078
+ <kbd
1079
+ aria-hidden
1080
+ className="ms-0.5 hidden rounded border border-current/20 px-1 font-sans text-[10px] leading-4 opacity-60 sm:inline"
1081
+ >
1082
+ {children}
1083
+ </kbd>
1084
+ );
1085
+ }