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,187 @@
1
+ /**
2
+ * Render Markdown balasan model — `react-markdown` + `remark-gfm`.
3
+ *
4
+ * Balasan opencode berformat Markdown, jadi sebelumnya tampil sebagai teks
5
+ * mentah (`**tebal**`, pipa tabel, fence kode). Komponen ini merendernya jadi
6
+ * elemen HTML bergaya sistem desain aplikasi.
7
+ *
8
+ * Keputusan penting:
9
+ * - **Tanpa `rehype-raw`.** HTML mentah di dalam Markdown TIDAK dirender
10
+ * sebagai DOM (di-escape jadi teks). Balasan model adalah konten tak
11
+ * terpercaya — ia bisa memuat markup dari file/web yang dibacanya —
12
+ * sehingga mengizinkan HTML mentah membuka pintu XSS. `disallowedElements`
13
+ * juga membuang tag pemuat sumber eksternal agar sintaks `![]()` tidak
14
+ * memicu request keluar.
15
+ * - **Tabel dibungkus scroll horizontal.** Tabel lebar tidak boleh memaksa
16
+ * seluruh bubble melebar (dan merusak layout HP); wrapper `overflow-x-auto`
17
+ * membuat hanya tabelnya yang bergeser. Blok kode diperlakukan sama.
18
+ * - **Link buka tab baru** dengan `rel="noopener noreferrer nofollow"`.
19
+ * - **Blok kode di-highlight** lewat `CodeBlock` (Shiki, grammar dimuat
20
+ * lazy per bahasa). Kode inline tetap teks monospace biasa.
21
+ * - Parser remark bersifat recoverable, jadi Markdown setengah jadi saat
22
+ * streaming tetap dirender tanpa error (fence yang belum ditutup tampil
23
+ * sebagai kode berjalan, lalu rapi begitu token penutup tiba).
24
+ */
25
+
26
+ import { Children, type ComponentProps, isValidElement, type JSX, type ReactNode } from "react";
27
+ import ReactMarkdown from "react-markdown";
28
+ import remarkGfm from "remark-gfm";
29
+ import { CodeBlock } from "@/components/sessions/CodeBlock";
30
+ import { cn } from "@/lib/utils";
31
+
32
+ /**
33
+ * Elemen yang tidak pernah dirender: pemuat sumber eksternal & elemen
34
+ * berpotensi eksekusi.
35
+ */
36
+ const DISALLOWED = ["img", "iframe", "script", "style", "video", "audio", "embed", "object"];
37
+
38
+ /**
39
+ * Props yang diterima komponen kustom react-markdown. Selain atribut HTML,
40
+ * react-markdown menyertakan `node` (AST hast) — prop internal yang HARUS
41
+ * dibuang sebelum di-spread ke elemen DOM, kalau tidak ia bocor jadi atribut
42
+ * `node="[object Object]"` di HTML hasil render.
43
+ */
44
+ type MarkdownProps<T extends keyof JSX.IntrinsicElements> = ComponentProps<T> & {
45
+ node?: unknown;
46
+ };
47
+
48
+ /**
49
+ * Bangun komponen bergaya untuk satu tag HTML: `node` dibuang, `className`
50
+ * dari markdown (mis. `language-ts` pada blok kode) tetap digabung.
51
+ */
52
+ function styled<T extends keyof JSX.IntrinsicElements>(tag: T, classes: string) {
53
+ return function StyledTag({ node: _node, className, ...props }: MarkdownProps<T>) {
54
+ const Tag = tag as string;
55
+ return <Tag {...props} className={cn(classes, className)} />;
56
+ };
57
+ }
58
+
59
+ /** Tautan aman: selalu tab baru, tanpa membocorkan referrer. */
60
+ function MarkdownLink({ node: _node, className, ...props }: MarkdownProps<"a">) {
61
+ return (
62
+ <a
63
+ {...props}
64
+ target="_blank"
65
+ rel="noopener noreferrer nofollow"
66
+ className={cn(
67
+ "font-medium text-primary underline underline-offset-2 hover:no-underline",
68
+ className,
69
+ )}
70
+ />
71
+ );
72
+ }
73
+
74
+ /**
75
+ * `code` melayani dua konteks: di dalam `pre` (blok) dan inline. Di dalam
76
+ * `pre`, gaya latar/padding dilepas agar tidak dobel dengan wrapper `pre`;
77
+ * react-markdown menandai blok lewat kelas `language-*`.
78
+ */
79
+ function MarkdownCode({ node: _node, className, ...props }: MarkdownProps<"code">) {
80
+ const isBlock = typeof className === "string" && className.includes("language-");
81
+ return (
82
+ <code
83
+ {...props}
84
+ className={cn(
85
+ "font-mono",
86
+ isBlock
87
+ ? "bg-transparent p-0 text-base"
88
+ : "rounded bg-muted px-1 py-0.5 text-[0.9em] wrap-anywhere",
89
+ className,
90
+ )}
91
+ />
92
+ );
93
+ }
94
+
95
+ /** Gabungkan anak `code` jadi string (react-markdown memberi teks mentah). */
96
+ function textOf(node: ReactNode): string {
97
+ if (typeof node === "string" || typeof node === "number") return String(node);
98
+ if (Array.isArray(node)) return node.map(textOf).join("");
99
+ if (isValidElement<{ children?: ReactNode }>(node)) return textOf(node.props.children);
100
+ return "";
101
+ }
102
+
103
+ /**
104
+ * Blok kode: `pre > code.language-*`. Isi & bahasa diambil dari `code`
105
+ * anak lalu dirender `CodeBlock` (highlight + tombol salin). Fence tanpa
106
+ * bahasa tetap lewat `CodeBlock` (teks polos, tetap ada tombol salin).
107
+ */
108
+ function MarkdownPre({ node: _node, children }: MarkdownProps<"pre">) {
109
+ const child = Children.toArray(children)[0];
110
+ if (isValidElement<{ className?: string; children?: ReactNode }>(child)) {
111
+ const cls = child.props.className ?? "";
112
+ const lang = /(?:^|\s)language-(\S+)/.exec(cls)?.[1] ?? null;
113
+ // Fence selalu diakhiri newline oleh parser — buang agar tidak ada
114
+ // baris kosong ekstra di bawah blok.
115
+ const code = textOf(child.props.children).replace(/\n$/, "");
116
+ return <CodeBlock code={code} language={lang} />;
117
+ }
118
+ return <pre className="my-2 overflow-x-auto rounded-lg border bg-muted/60 p-3">{children}</pre>;
119
+ }
120
+
121
+ /**
122
+ * Tabel + wrapper scroll horizontal. Wrapper `div` diperlukan karena elemen
123
+ * `table` sendiri tidak dapat menjadi kontainer scroll.
124
+ */
125
+ function MarkdownTable({ node: _node, className, ...props }: MarkdownProps<"table">) {
126
+ return (
127
+ <div className="my-2 max-w-full overflow-x-auto rounded-lg border first:mt-0 last:mb-0">
128
+ <table {...props} className={cn("w-full border-collapse text-left text-base", className)} />
129
+ </div>
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Peta komponen: gaya memakai token sistem desain (bukan warna hardcoded)
135
+ * supaya tema terang/gelap ikut menyesuaikan.
136
+ */
137
+ const COMPONENTS = {
138
+ a: MarkdownLink,
139
+ code: MarkdownCode,
140
+ table: MarkdownTable,
141
+ // Blok kode: highlight + scroll horizontal sendiri (lihat `CodeBlock`).
142
+ pre: MarkdownPre,
143
+ p: styled("p", "my-2 first:mt-0 last:mb-0"),
144
+ h1: styled("h1", "mt-4 mb-2 text-lg font-semibold first:mt-0"),
145
+ h2: styled("h2", "mt-4 mb-2 text-lg font-semibold first:mt-0"),
146
+ h3: styled("h3", "mt-3 mb-1.5 text-base font-semibold first:mt-0"),
147
+ h4: styled("h4", "mt-3 mb-1.5 text-base font-semibold first:mt-0"),
148
+ h5: styled("h5", "mt-3 mb-1 text-base font-semibold first:mt-0"),
149
+ h6: styled("h6", "mt-3 mb-1 text-base font-semibold first:mt-0"),
150
+ ul: styled("ul", "my-2 list-disc space-y-1 ps-5 first:mt-0 last:mb-0"),
151
+ ol: styled("ol", "my-2 list-decimal space-y-1 ps-5 first:mt-0 last:mb-0"),
152
+ li: styled("li", "marker:text-muted-foreground"),
153
+ blockquote: styled(
154
+ "blockquote",
155
+ "my-2 border-s-2 border-border ps-3 text-muted-foreground italic first:mt-0 last:mb-0",
156
+ ),
157
+ hr: styled("hr", "my-3 border-border"),
158
+ thead: styled("thead", "bg-muted/60"),
159
+ th: styled("th", "border-b px-3 py-2 font-semibold whitespace-nowrap"),
160
+ td: styled("td", "border-b px-3 py-2 align-top"),
161
+ strong: styled("strong", "font-semibold"),
162
+ em: styled("em", "italic"),
163
+ del: styled("del", "text-muted-foreground line-through"),
164
+ };
165
+
166
+ export interface MarkdownContentProps {
167
+ /** Sumber Markdown (teks balasan model). */
168
+ children: string;
169
+ className?: string;
170
+ }
171
+
172
+ export function MarkdownContent({ children, className }: MarkdownContentProps) {
173
+ return (
174
+ // react-markdown v10 tidak lagi menerima prop `className` — kelas dipasang
175
+ // pada elemen pembungkus di sini.
176
+ <div className={cn("min-w-0", className)}>
177
+ <ReactMarkdown
178
+ remarkPlugins={[remarkGfm]}
179
+ disallowedElements={DISALLOWED}
180
+ unwrapDisallowed
181
+ components={COMPONENTS}
182
+ >
183
+ {children}
184
+ </ReactMarkdown>
185
+ </div>
186
+ );
187
+ }
@@ -0,0 +1,47 @@
1
+ import { cn } from "@/lib/utils";
2
+ import logoUrl from "@/logo.svg";
3
+
4
+ /**
5
+ * Maskot status turn: logo KCG Code + keterangan proses yang sedang
6
+ * berjalan ("Working…", "Thinking…", "read package.json", "Writing…").
7
+ *
8
+ * Tiga mode:
9
+ * - `active` : logo berdenyut (pulse) + label status (turn masih berjalan).
10
+ * - `standby` : logo diam tanpa label — penanda model "hadir" setelah pesan
11
+ * terakhir selesai (masih dirender, tidak disembunyikan).
12
+ * - keduanya off + label kosong -> tidak dirender (belum ada turn).
13
+ */
14
+ export function Mascot({
15
+ label,
16
+ active = false,
17
+ standby = false,
18
+ className,
19
+ }: {
20
+ /** Teks status; kosong/null -> mode standby (logo saja) bila `standby`. */
21
+ label: string | null;
22
+ /** true -> logo beranimasi (turn masih berjalan). */
23
+ active?: boolean;
24
+ /** true -> tetap tampil sebagai logo diam walau tanpa label (selesai). */
25
+ standby?: boolean;
26
+ className?: string;
27
+ }) {
28
+ const empty = label === null || label === "";
29
+ // Tanpa label & bukan standby -> tidak ada yang perlu dirender.
30
+ if (empty && !standby) return null;
31
+
32
+ return (
33
+ <div
34
+ className={cn("flex items-center gap-2.5 py-1 text-sm text-muted-foreground", className)}
35
+ role="status"
36
+ aria-live="polite"
37
+ >
38
+ <img
39
+ src={logoUrl}
40
+ alt=""
41
+ aria-hidden
42
+ className={cn("size-5 shrink-0", active && "animate-pulse", empty && "opacity-60")}
43
+ />
44
+ {!empty && <span className="truncate">{label}</span>}
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Aksi per pesan di footer (saat ini: Copy).
3
+ *
4
+ * Di perangkat ber-mouse (`pointer-fine`) tombol baru terlihat saat pesan
5
+ * di-hover / difokus agar percakapan tetap bersih; di layar sentuh selalu
6
+ * tampil (tidak ada hover). Pola sama dengan menu "..." di `SessionRow`.
7
+ */
8
+ import { CheckIcon, CopyIcon } from "lucide-react";
9
+ import { useEffect, useState } from "react";
10
+ import { toast } from "sonner";
11
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
12
+ import { cn } from "@/lib/utils";
13
+
14
+ export function CopyMessageButton({ text, className }: { text: string; className?: string }) {
15
+ const [copied, setCopied] = useState(false);
16
+
17
+ useEffect(() => {
18
+ if (!copied) return;
19
+ const t = setTimeout(() => setCopied(false), 1500);
20
+ return () => clearTimeout(t);
21
+ }, [copied]);
22
+
23
+ if (text.trim() === "") return null;
24
+
25
+ const copy = async () => {
26
+ try {
27
+ await navigator.clipboard.writeText(text);
28
+ setCopied(true);
29
+ } catch {
30
+ // Clipboard API butuh konteks aman (HTTPS / localhost).
31
+ toast.error("Couldn't copy. Select the text and copy it manually.");
32
+ }
33
+ };
34
+
35
+ return (
36
+ <Tooltip>
37
+ <TooltipTrigger asChild>
38
+ <button
39
+ type="button"
40
+ onClick={() => void copy()}
41
+ aria-label={copied ? "Copied" : "Copy message"}
42
+ className={cn(
43
+ "flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-[opacity,color,background-color] outline-none hover:bg-muted hover:text-foreground focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50",
44
+ // Mouse: tampil saat pesan di-hover / ada fokus di dalamnya.
45
+ "pointer-fine:opacity-0 pointer-fine:group-hover/message:opacity-100 pointer-fine:group-focus-within/message:opacity-100",
46
+ copied && "opacity-100 pointer-fine:opacity-100",
47
+ className,
48
+ )}
49
+ >
50
+ {copied ? (
51
+ <CheckIcon className="size-3.5 text-emerald-600 dark:text-emerald-400" aria-hidden />
52
+ ) : (
53
+ <CopyIcon className="size-3.5" aria-hidden />
54
+ )}
55
+ </button>
56
+ </TooltipTrigger>
57
+ <TooltipContent>{copied ? "Copied" : "Copy"}</TooltipContent>
58
+ </Tooltip>
59
+ );
60
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Rail penanda pesan user di tepi kiri timeline (ala Gemini).
3
+ *
4
+ * Satu garis pendek & tebal per pesan user, urut dari atas ke bawah. Diklik
5
+ * -> melompat ke pesan itu; di-hover -> garis memanjang dan berbunyi satu
6
+ * nada. Nadanya menaik dari atas ke bawah (tangga nada mayor: do re mi …),
7
+ * jadi menyapu rail terdengar seperti memainkan tangga nada. Penanda yang
8
+ * sedang tampil di layar dibuat lebih kontras sehingga rail sekaligus jadi
9
+ * indikator posisi baca.
10
+ *
11
+ * Catatan implementasi:
12
+ * - Posisi penanda TIDAK proporsional terhadap tinggi dokumen (bukan
13
+ * minimap): jaraknya tetap, ditempatkan di tengah tepi kiri.
14
+ * - Jumlah penanda tidak dibatasi (tiap pesan user selalu dapat satu).
15
+ * Percakapan panjang -> rail melebihi tinggi maksimumnya lalu bisa
16
+ * di-scroll; rail MENGGESER DIRINYA agar penanda aktif selalu terlihat,
17
+ * sehingga penanda lama tetap terjangkau tanpa perlu dibuang.
18
+ * - Penanda aktif ditentukan `IntersectionObserver` di `SessionTimeline`
19
+ * (elemen pesan user), jadi tidak ada perhitungan scroll manual.
20
+ * - Disembunyikan di layar sempit (`hidden lg:flex`): tidak ada ruang di
21
+ * tepi kiri HP, dan rail bukan satu-satunya cara navigasi.
22
+ * - Hanya muncul bila ada ≥ 2 pesan user (satu penanda tidak berguna).
23
+ */
24
+ import { useEffect, useRef } from "react";
25
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
26
+ import { playTone, scaleFrequency } from "@/lib/tone";
27
+ import { cn } from "@/lib/utils";
28
+
29
+ /**
30
+ * Jeda sebelum tooltip muncul. Provider global memakai 0 (tombol ikon perlu
31
+ * respons cepat), tapi di rail penanda berdempetan: tanpa jeda, menyapu
32
+ * pointer melintasinya memunculkan tooltip berkedip-kedip. Nilai ini
33
+ * menimpa provider hanya untuk rail.
34
+ */
35
+ const TOOLTIP_DELAY_MS = 450;
36
+
37
+ export interface UserMessageMark {
38
+ id: string;
39
+ /** Cuplikan teks pesan untuk tooltip & label aksesibilitas. */
40
+ preview: string;
41
+ }
42
+
43
+ export interface MessageRailProps {
44
+ marks: UserMessageMark[];
45
+ /** Id pesan user yang sedang tampil (penanda aktif). */
46
+ activeId: string | null;
47
+ onJump: (id: string) => void;
48
+ }
49
+
50
+ export function MessageRail({ marks, activeId, onJump }: MessageRailProps) {
51
+ const listRef = useRef<HTMLUListElement | null>(null);
52
+
53
+ // Rail lebih tinggi dari batasnya -> jaga penanda aktif tetap dalam
54
+ // pandangan. `block: "nearest"` hanya menggeser bila penanda memang di luar
55
+ // area, jadi rail tidak bergerak-gerak saat penanda aktif sudah terlihat.
56
+ useEffect(() => {
57
+ const list = listRef.current;
58
+ if (list === null || activeId === null) return;
59
+ if (list.scrollHeight <= list.clientHeight) return;
60
+ // Id pesan berbentuk `usr_<uuid>`, tapi selector tetap di-escape bila
61
+ // `CSS.escape` tersedia — id tak lazim tidak boleh merusak rail.
62
+ const escaped = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(activeId) : activeId;
63
+ const el = list.querySelector<HTMLElement>(`[data-mark-id="${escaped}"]`);
64
+ el?.scrollIntoView({ block: "nearest" });
65
+ }, [activeId]);
66
+
67
+ if (marks.length < 2) return null;
68
+
69
+ return (
70
+ <nav
71
+ aria-label="Your messages"
72
+ className="pointer-events-none absolute top-1/2 left-4 hidden -translate-y-1/2 lg:flex xl:left-6"
73
+ >
74
+ <ul
75
+ ref={listRef}
76
+ className="pointer-events-auto flex max-h-[60vh] flex-col items-center gap-px overflow-y-auto py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
77
+ >
78
+ {marks.map((mark, i) => {
79
+ const active = mark.id === activeId;
80
+ return (
81
+ <li key={mark.id} data-mark-id={mark.id}>
82
+ <Tooltip delayDuration={TOOLTIP_DELAY_MS}>
83
+ <TooltipTrigger asChild>
84
+ <button
85
+ type="button"
86
+ onClick={() => onJump(mark.id)}
87
+ // Nada hanya saat pointer masuk (bukan tiap gerakan), dan
88
+ // tidak untuk fokus keyboard agar Tab tidak berbunyi.
89
+ onPointerEnter={(e) => {
90
+ // Sentuhan langsung jadi klik — nada hover tidak relevan.
91
+ if (e.pointerType !== "touch") playTone(scaleFrequency(i));
92
+ }}
93
+ aria-label={`Jump to your message ${i + 1}: ${mark.preview}`}
94
+ aria-current={active ? "true" : undefined}
95
+ // Area klik 24px (nyaman ditunjuk), garisnya sendiri tipis.
96
+ className="group/mark flex h-3 w-7 items-center justify-center rounded-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40"
97
+ >
98
+ <span
99
+ aria-hidden
100
+ className={cn(
101
+ // Pendek & agak tebal; memanjang saat hover / fokus.
102
+ "block h-[3px] rounded-full transition-[width,background-color] duration-200",
103
+ "group-hover/mark:w-7 group-focus-visible/mark:w-7",
104
+ active
105
+ ? "w-[18px] bg-foreground/70"
106
+ : "w-3.5 bg-foreground/30 group-hover/mark:bg-foreground/60",
107
+ )}
108
+ />
109
+ </button>
110
+ </TooltipTrigger>
111
+ <TooltipContent side="right" className="max-w-56">
112
+ <span className="line-clamp-2">{mark.preview}</span>
113
+ </TooltipContent>
114
+ </Tooltip>
115
+ </li>
116
+ );
117
+ })}
118
+ </ul>
119
+ </nav>
120
+ );
121
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Pemilih model LLM untuk sebuah Session (meniru pilihan model di opencode).
3
+ *
4
+ * Dropdown `Select` sebelumnya menyulitkan saat provider punya puluhan model:
5
+ * tidak ada pencarian, dan di layar HP daftarnya panjang sekali. Sekarang
6
+ * trigger membuka dialog berisi `ModelSearchList` (kolom cari + daftar
7
+ * berkelompok per provider, model aktif ditandai).
8
+ *
9
+ * - Daftar model diambil dari `GET /api/projects/:id/models` — dimuat saat
10
+ * dialog dibuka (fetch ulang tiap kali agar config opencode yang baru
11
+ * ikut terbaca; server headless tidak di-spawn bila user tak pernah
12
+ * membuka picker).
13
+ * - Perubahan dikirim via `PUT /api/sessions/:id` dan berlaku pada prompt
14
+ * berikutnya tanpa restart Session.
15
+ * - Opsi "Model default" mengirim `model: null` (opencode memakai
16
+ * konfigurasi default-nya).
17
+ * - `sessionId: null` (composer homepage, Session belum dibuat): pilihan
18
+ * hanya diteruskan ke `onChanged` tanpa PUT — dipakai saat membuat Session.
19
+ * - `onAgentTypeChange` (hanya homepage): dialog menampilkan pengalih agent
20
+ * (OpenCode / Claude Code) di atas daftar, dan trigger membawa logo agent.
21
+ * Model bergantung pada agent, jadi keduanya dipilih di satu tempat.
22
+ */
23
+
24
+ import { ChevronDownIcon, ChevronsUpDownIcon, SparklesIcon } from "lucide-react";
25
+ import { useEffect, useState } from "react";
26
+ import { AgentLogo } from "@/components/sessions/AgentLogo";
27
+ import { ModelSearchList } from "@/components/sessions/ModelSearchList";
28
+ import { AdwDialogContent } from "@/components/ui/adw-dialog";
29
+ import { AdwToggleGroup } from "@/components/ui/adw-toggle-group";
30
+ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
31
+ import { Button } from "@/components/ui/button";
32
+ import { Dialog } from "@/components/ui/dialog";
33
+ import { Spinner } from "@/components/ui/spinner";
34
+ import { AGENT_TYPE_LABELS, AGENT_TYPES, agentQuery } from "@/lib/agent-types";
35
+ import { ApiError, apiFetch } from "@/lib/api";
36
+ import { activeModelLabel, keyOfSessionModel, shortModelName } from "@/lib/model-picker";
37
+ import type { ModelOption } from "@/server/services/opencode-client";
38
+ import type { AgentType, SessionModel } from "@/types";
39
+
40
+ export interface ModelPickerProps {
41
+ projectId: string;
42
+ /** Backend Session — daftar model opencode vs Claude Code berbeda. */
43
+ agentType?: AgentType;
44
+ /** null = Session belum ada; pilihan tidak disimpan ke server. */
45
+ sessionId: string | null;
46
+ /** Model aktif saat ini; null = default opencode. */
47
+ model: SessionModel | null;
48
+ /** Dipanggil setelah server menerima perubahan model. */
49
+ onChanged: (model: SessionModel | null) => void;
50
+ /**
51
+ * Bentuk trigger:
52
+ * - `heading` (default di header Session): judul besar tanpa border, nama
53
+ * model dipendekkan (`shortModelName`) — pola aplikasi chat mobile.
54
+ * - `chip`: tombol outline ringkas untuk ditempel di dalam toolbar.
55
+ * - `inline`: teks polos + chevron tanpa border/latar (baris di bawah
56
+ * composer homepage).
57
+ */
58
+ variant?: "heading" | "chip" | "inline";
59
+ /**
60
+ * Bila diisi, agent boleh diganti dari dialog ini (Session belum dibuat).
61
+ * Pemanggil bertanggung jawab me-reset `model` saat agent berganti.
62
+ */
63
+ onAgentTypeChange?: (agentType: AgentType) => void;
64
+ }
65
+
66
+ export function ModelPicker({
67
+ projectId,
68
+ agentType = "opencode",
69
+ sessionId,
70
+ model,
71
+ onChanged,
72
+ variant = "chip",
73
+ onAgentTypeChange,
74
+ }: ModelPickerProps) {
75
+ const [open, setOpen] = useState(false);
76
+ const [models, setModels] = useState<ModelOption[] | null>(null);
77
+ const [loading, setLoading] = useState(false);
78
+ const [saving, setSaving] = useState(false);
79
+ const [error, setError] = useState<string | null>(null);
80
+
81
+ const label = activeModelLabel(model, models ?? []);
82
+ /** Judul header: buang prefiks vendor & potong bila panjang. */
83
+ const headingLabel = shortModelName(label);
84
+ /**
85
+ * Trigger `inline` di homepage: tanpa model pilihan, tampilkan nama agent
86
+ * ("Claude Code") — "Default model" terpotong di HP dan bertabrakan dengan
87
+ * label mode agent ("Default") di composer.
88
+ */
89
+ const inlineLabel =
90
+ onAgentTypeChange && model === null ? AGENT_TYPE_LABELS[agentType] : headingLabel;
91
+
92
+ // Daftar model selalu di-fetch ulang saat dialog dibuka — list di server
93
+ // headless bisa berubah bila user mengedit config opencode (`opencode serve`
94
+ // tidak hot-reload). Lazy: tidak di-spawn saat halaman Session dibuka.
95
+ useEffect(() => {
96
+ if (!open) return;
97
+ // Agent bisa diganti saat dialog terbuka — abaikan respons fetch lama.
98
+ let stale = false;
99
+ setLoading(true);
100
+ setError(null);
101
+ apiFetch(`/api/projects/${projectId}/models${agentQuery(agentType)}`)
102
+ .then(async (res) => {
103
+ const body = (await res.json()) as { models: ModelOption[] };
104
+ if (!stale) setModels(body.models);
105
+ })
106
+ .catch((e: unknown) => {
107
+ if (stale) return;
108
+ setError(e instanceof ApiError ? e.message : "Failed to load model list");
109
+ setModels([]);
110
+ })
111
+ .finally(() => {
112
+ if (!stale) setLoading(false);
113
+ });
114
+ return () => {
115
+ stale = true;
116
+ };
117
+ }, [open, projectId, agentType]);
118
+
119
+ const choose = async (next: SessionModel | null) => {
120
+ setSaving(true);
121
+ setError(null);
122
+ try {
123
+ if (sessionId !== null) {
124
+ await apiFetch(`/api/sessions/${sessionId}`, {
125
+ method: "PUT",
126
+ body: JSON.stringify({ model: next }),
127
+ });
128
+ }
129
+ onChanged(next);
130
+ setOpen(false);
131
+ } catch (e) {
132
+ setError(e instanceof ApiError ? e.message : "Failed to change model");
133
+ } finally {
134
+ setSaving(false);
135
+ }
136
+ };
137
+
138
+ return (
139
+ <>
140
+ {variant === "heading" ? (
141
+ // Judul header: nama model + chevron sebagai isyarat "bisa diganti".
142
+ <Button
143
+ type="button"
144
+ variant="ghost"
145
+ onClick={() => setOpen(true)}
146
+ disabled={saving}
147
+ className="h-8 max-w-full min-w-0 gap-1 px-2 text-base font-semibold"
148
+ aria-label={`Model: ${label}. Tap to change model`}
149
+ title={`Model: ${label}`}
150
+ >
151
+ <span className="min-w-0 truncate">{headingLabel}</span>
152
+ {saving ? (
153
+ <Spinner className="size-4 shrink-0" />
154
+ ) : (
155
+ <ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" />
156
+ )}
157
+ </Button>
158
+ ) : variant === "inline" ? (
159
+ <Button
160
+ type="button"
161
+ variant="ghost"
162
+ size="sm"
163
+ onClick={() => setOpen(true)}
164
+ disabled={saving}
165
+ className="h-9 max-w-full min-w-0 gap-1 px-2 font-normal text-muted-foreground hover:text-foreground"
166
+ aria-label={
167
+ onAgentTypeChange
168
+ ? `${AGENT_TYPE_LABELS[agentType]}, model: ${label}. Tap to change`
169
+ : `Model: ${label}. Tap to change`
170
+ }
171
+ title={onAgentTypeChange ? `${AGENT_TYPE_LABELS[agentType]} · ${label}` : label}
172
+ >
173
+ {onAgentTypeChange && <AgentLogo agentType={agentType} />}
174
+ <span className="min-w-0 truncate">{inlineLabel}</span>
175
+ {saving ? <Spinner data-icon="inline-end" /> : <ChevronDownIcon data-icon="inline-end" />}
176
+ </Button>
177
+ ) : (
178
+ <Button
179
+ type="button"
180
+ variant="outline"
181
+ size="sm"
182
+ onClick={() => setOpen(true)}
183
+ disabled={saving}
184
+ className="h-9 max-w-full min-w-0 justify-between gap-1.5 px-2.5 font-normal"
185
+ aria-label={`Model: ${label}. Tap to change`}
186
+ title={label}
187
+ >
188
+ <SparklesIcon className="size-4 shrink-0 text-muted-foreground" />
189
+ <span className="min-w-0 truncate text-sm">{label}</span>
190
+ {saving ? (
191
+ <Spinner className="size-4 shrink-0" />
192
+ ) : (
193
+ <ChevronsUpDownIcon className="size-4 shrink-0 text-muted-foreground" />
194
+ )}
195
+ </Button>
196
+ )}
197
+
198
+ <Dialog
199
+ open={open}
200
+ onOpenChange={(next) => {
201
+ if (saving) return;
202
+ setOpen(next);
203
+ if (!next) setError(null);
204
+ }}
205
+ >
206
+ {/* AdwDialog: header bar (judul + subjudul di tengah, tombol tutup
207
+ bundar), isi preferensi di bawahnya; bottom sheet di HP. */}
208
+ <AdwDialogContent
209
+ title={onAgentTypeChange ? "Agent & Model" : "Model"}
210
+ subtitle={sessionId === null ? "For the new session" : "Applies to the next message"}
211
+ closeDisabled={saving}
212
+ >
213
+ {onAgentTypeChange && (
214
+ <AdwToggleGroup
215
+ label="Agent"
216
+ value={agentType}
217
+ // Tetap bisa diganti saat daftar dimuat — respons usang dibuang.
218
+ disabled={saving}
219
+ onChange={onAgentTypeChange}
220
+ options={AGENT_TYPES.map((t) => ({
221
+ value: t,
222
+ label: AGENT_TYPE_LABELS[t],
223
+ icon: <AgentLogo agentType={t} />,
224
+ }))}
225
+ />
226
+ )}
227
+
228
+ {error && (
229
+ <Alert variant="destructive">
230
+ <AlertTitle>Failed</AlertTitle>
231
+ <AlertDescription>{error}</AlertDescription>
232
+ </Alert>
233
+ )}
234
+
235
+ <ModelSearchList
236
+ models={models}
237
+ loading={loading}
238
+ activeKey={keyOfSessionModel(model)}
239
+ onSelect={(next) => void choose(next)}
240
+ disabled={saving}
241
+ className="flex-1"
242
+ />
243
+ </AdwDialogContent>
244
+ </Dialog>
245
+ </>
246
+ );
247
+ }