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,641 @@
1
+ /**
2
+ * Composer input Session — textarea prompt, lampiran file (gambar maupun
3
+ * file lain; lewat tombol, paste, atau drag & drop), @file autocomplete,
4
+ * mode agent, serta tombol kirim/stop.
5
+ *
6
+ * Drag & drop: overlay "Add anything" dipasang `SessionView` (menutupi
7
+ * seluruh layar Session) dan memanggil `addFiles(files, fromRect)` lewat
8
+ * ref. Dengan `fromRect`, file baru dianimasikan dari kartu overlay ke
9
+ * deretan lampiran (`DropPlate`).
10
+ *
11
+ * Seluruh state input (teks, gambar pending, mention, agent mode) hidup di
12
+ * komponen ini; engine (`useSessionChat`) hanya menyuplai izin input & kanal
13
+ * kirim. Saat model merespon tombol kirim berubah jadi tombol Stop
14
+ * (mengirim `{ type: "interrupt" }`) — menghentikan balasan saja,
15
+ * Session tetap berjalan.
16
+ *
17
+ * UI punya dua varian:
18
+ * - Desktop: attach image | textarea | AgentPicker | kirim/stop.
19
+ * - Mobile: satu tombol aksi (attach image + agent mode) lewat `Sheet`;
20
+ * saat input lebih dari satu baris, textarea pindah ke baris sendiri.
21
+ */
22
+ import { PaperclipIcon, PlusIcon, SendHorizontalIcon, SquareIcon } from "lucide-react";
23
+ import {
24
+ type Ref,
25
+ useCallback,
26
+ useEffect,
27
+ useImperativeHandle,
28
+ useLayoutEffect,
29
+ useRef,
30
+ useState,
31
+ } from "react";
32
+ import { AgentPicker } from "@/components/sessions/AgentPicker";
33
+ import { ComposerActionSheet } from "@/components/sessions/ComposerActionSheet";
34
+ import { DropPlate } from "@/components/sessions/DropPlate";
35
+ import { FileMentionDropdown } from "@/components/sessions/FileMentionDropdown";
36
+ import {
37
+ type PendingAttachment,
38
+ PendingAttachments,
39
+ } from "@/components/sessions/PendingAttachments";
40
+ import { Button } from "@/components/ui/button";
41
+ import { Sheet, SheetTrigger } from "@/components/ui/sheet";
42
+ import { Spinner } from "@/components/ui/spinner";
43
+ import { useAgentPicker } from "@/hooks/useAgentPicker";
44
+ import { useIsMobile } from "@/hooks/useMediaQuery";
45
+ import { useFileMention } from "@/hooks/useMention";
46
+ import type { WsConnectionStatus } from "@/hooks/useWebSocket";
47
+ import { ApiError, apiErrorMessage, apiUploadFile } from "@/lib/api";
48
+ import { isPreviewableImage, MAX_ATTACHMENT_BYTES } from "@/lib/attachments";
49
+ import { composerPlaceholder, extractMentionedFiles, splitComposerMentions } from "@/lib/composer";
50
+ import { CONVERSATION_MAX_W } from "@/lib/layout";
51
+ import { cn } from "@/lib/utils";
52
+ import type { Session } from "@/types";
53
+ import type { ClientMessage } from "@/ws-protocol";
54
+
55
+ /** Handle imperatif composer (dipakai overlay drag & drop). */
56
+ export interface SessionComposerHandle {
57
+ /** Lampirkan file; `from` = rect asal animasi (kartu overlay). */
58
+ addFiles: (files: File[], from?: DOMRect | null) => void;
59
+ }
60
+
61
+ export interface SessionComposerProps {
62
+ ref?: Ref<SessionComposerHandle>;
63
+ session: Session;
64
+ /** Engine: input boleh dipakai (koneksi + Session running + tidak sibuk). */
65
+ inputAllowed: boolean;
66
+ /** Model sedang merespon (untuk placeholder & tombol Stop). */
67
+ busy: boolean;
68
+ /** Model merespon DAN Session berjalan — kirim berubah jadi Stop. */
69
+ generating: boolean;
70
+ wsStatus: WsConnectionStatus;
71
+ /** Kirim pesan Client -> Server (engine). */
72
+ send: (msg: ClientMessage) => void;
73
+ /** Set error banner global (upload/kirim gagal). */
74
+ reportError: (message: string | null) => void;
75
+ /** Hentikan balasan model (interrupt) — Session tetap aktif. */
76
+ onInterrupt: () => void;
77
+ }
78
+
79
+ export function SessionComposer({
80
+ ref,
81
+ session,
82
+ inputAllowed,
83
+ busy,
84
+ generating,
85
+ wsStatus,
86
+ send,
87
+ reportError,
88
+ onInterrupt,
89
+ }: SessionComposerProps) {
90
+ /** Layar sempit: placeholder composer dipendekkan agar tidak terpotong. */
91
+ const isMobile = useIsMobile();
92
+ const [text, setText] = useState("");
93
+ /**
94
+ * File yang akan dilampirkan ke pesan berikutnya (belum di-upload).
95
+ * Thumbnail gambar memakai object URL lokal; upload terjadi saat kirim.
96
+ */
97
+ const [pendingImages, setPendingImages] = useState<PendingAttachment[]>([]);
98
+ /**
99
+ * Animasi plate drop yang sedang berjalan: item-nya sudah ada di
100
+ * `pendingImages` (tak terlihat, jadi tujuan animasi) sampai plate
101
+ * mendarat. `targets` diisi setelah layout diukur.
102
+ */
103
+ const [plate, setPlate] = useState<{
104
+ from: DOMRect;
105
+ keys: string[];
106
+ targets: { item: PendingAttachment; rect: DOMRect }[] | null;
107
+ /** Kotak composer: plate menyusut menjadi kotak ini lalu larut ke dalamnya. */
108
+ box: DOMRect | null;
109
+ } | null>(null);
110
+ /** Kotak composer (tujuan akhir latar plate). */
111
+ const boxRef = useRef<HTMLDivElement | null>(null);
112
+ /** Elemen tiap lampiran (untuk mengukur tujuan animasi plate). */
113
+ const attachmentEls = useRef(new Map<string, HTMLElement>());
114
+ /** Upload lampiran sedang berjalan — cegah kirim ganda. */
115
+ const [sending, setSending] = useState(false);
116
+ /**
117
+ * Input butuh lebih dari satu baris — tombol aksi & kirim/stop pindah ke
118
+ * baris di bawah textarea (ala Gemini), di desktop maupun mobile.
119
+ */
120
+ const [multiline, setMultiline] = useState(false);
121
+ /** Sheet aksi mobile (attach image + agent mode) di tombol tunggal kiri input. */
122
+ const [actionSheetOpen, setActionSheetOpen] = useState(false);
123
+ /** Agent (mode) pilihan Session — build/plan/agent kustom; null = default. */
124
+ const [agent, setAgent] = useState<string | null>(session.agent);
125
+ /**
126
+ * Fetch + pick daftar agent untuk Sheet aksi mobile — dipakai langsung
127
+ * (tanpa nested popover) supaya tap satu mode langsung menerapkannya.
128
+ */
129
+ const agentPicker = useAgentPicker(session.projectId, session.id, setAgent, session.agentType);
130
+ // Muat daftar agent begitu Sheet aksi mobile pertama dibuka (lazy, sama
131
+ // seperti popover AgentPicker di desktop).
132
+ useEffect(() => {
133
+ if (actionSheetOpen) agentPicker.load();
134
+ }, [actionSheetOpen, agentPicker.load]);
135
+ /**
136
+ * Autocomplete `@file`: deteksi token @query di sekitar kursor + daftar
137
+ * saran dari server (index file milik opencode, seperti `@` di TUI-nya).
138
+ */
139
+ const mention = useFileMention({ endpoint: `/api/sessions/${session.id}/files` });
140
+ /**
141
+ * Terapkan teks hasil pemilihan saran (keyboard maupun mouse) ke state
142
+ * input + kembalikan fokus ke textarea.
143
+ */
144
+ const applyPicked = useCallback((next: string) => {
145
+ setText(next);
146
+ requestAnimationFrame(() => textareaRef.current?.focus());
147
+ }, []);
148
+ useEffect(() => mention.setOnPick(applyPicked), [mention, applyPicked]);
149
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
150
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
151
+ /** Lapisan sorot @file di belakang textarea (scroll disinkronkan). */
152
+ const highlightRef = useRef<HTMLDivElement | null>(null);
153
+ const formRef = useRef<HTMLFormElement | null>(null);
154
+ /** Pengukur lebar teks satu baris — font identik dengan textarea. */
155
+ const measureRef = useRef<HTMLSpanElement | null>(null);
156
+ /**
157
+ * Perkiraan lebar kontrol yang berbagi baris dengan textarea saat inline —
158
+ * SENGAJA dilebihkan (attach + agent + kirim di desktop; plus + kirim di
159
+ * mobile) agar teks di dekat batas selalu dianggap multiline, bukan
160
+ * pas-pasan (sumber flip-flop). Mobile lebih kecil karena barisnya hanya
161
+ * dua tombol.
162
+ */
163
+ const INLINE_CONTROLS_RESERVE_PX = isMobile ? 88 : 192;
164
+ /**
165
+ * Putuskan `multiline` dari LEBAR TEKS terhadap lebar baris inline — BUKAN
166
+ * dari scrollHeight textarea. Mengukur tinggi membuat keputusan bergantung
167
+ * pada layout AKTIF: teks yang wrap di baris inline (sempit, dipotong
168
+ * tombol) bisa muat satu baris saat stacked (lebar penuh) → stacked →
169
+ * remeasure muat → inline → wrap lagi → … (flip-flop tiap ketik). Lebar
170
+ * teks (span tersembunyi) vs lebar form TIDAK berubah saat layout
171
+ * berganti, sehingga keputusannya selalu titik tetap yang stabil.
172
+ */
173
+ const updateMultiline = useCallback(() => {
174
+ const span = measureRef.current;
175
+ const form = formRef.current;
176
+ if (span === null || form === null) return;
177
+ const inlineTextWidth =
178
+ form.clientWidth -
179
+ 16 /* p-2 kontainer */ -
180
+ 16 /* px-2 textarea */ -
181
+ INLINE_CONTROLS_RESERVE_PX;
182
+ setMultiline(span.scrollWidth > inlineTextWidth);
183
+ }, [INLINE_CONTROLS_RESERVE_PX]);
184
+ /**
185
+ * Tumbuhkan tinggi textarea mengikuti isi (maks lewat CSS max-h). Textarea
186
+ * KOSONG dikembalikan ke tinggi CSS-nya (satu baris): di beberapa browser
187
+ * `scrollHeight` ikut menghitung placeholder yang wrap, sehingga textarea
188
+ * kosong tumbuh dua baris & jadi scrollable (mis. saat lampiran gambar
189
+ * mempersempit ruang teks).
190
+ */
191
+ const autoResize = useCallback((el: HTMLTextAreaElement) => {
192
+ el.style.height = "auto";
193
+ if (el.value === "") return;
194
+ el.style.height = `${el.scrollHeight}px`;
195
+ }, []);
196
+ // Sinkronkan pengukur lebar + keputusan stacked + tinggi textarea untuk
197
+ // perubahan `text` apa pun (ketik, autocomplete @file, reset kirim).
198
+ // biome-ignore lint/correctness/useExhaustiveDependencies: jumlah lampiran jadi pemicu (tombol kirim muncul -> lebar textarea berubah)
199
+ useLayoutEffect(() => {
200
+ const span = measureRef.current;
201
+ if (span !== null) span.textContent = text;
202
+ updateMultiline();
203
+ if (textareaRef.current) autoResize(textareaRef.current);
204
+ // Lampiran menambah/menghapus tombol kirim -> lebar textarea berubah.
205
+ }, [text, pendingImages.length, updateMultiline, autoResize]);
206
+ // Lebar form berubah (resize window / orientasi HP) → keputusan stacked
207
+ // dihitung ulang terhadap lebar terbaru.
208
+ useEffect(() => {
209
+ const form = formRef.current;
210
+ if (form === null || typeof ResizeObserver === "undefined") return;
211
+ const observer = new ResizeObserver(updateMultiline);
212
+ observer.observe(form);
213
+ return () => observer.disconnect();
214
+ }, [updateMultiline]);
215
+
216
+ /** Cache path yang pernah disarankan autocomplete (validitas @ref saat kirim). */
217
+ const suggestedCacheRef = useRef<Set<string>>(new Set());
218
+ if (mention.suggestions.length > 0) {
219
+ for (const s of mention.suggestions) suggestedCacheRef.current.add(s);
220
+ }
221
+
222
+ /**
223
+ * Terima file dari file picker / paste / drag & drop, validasi, lalu
224
+ * simpan ke antrean lampiran. File kosong / folder / terlalu besar ditolak
225
+ * dengan pesan. `from` (rect kartu overlay) -> animasi plate.
226
+ */
227
+ const addFiles = useCallback(
228
+ (files: Iterable<File>, from?: DOMRect | null) => {
229
+ const picked: PendingAttachment[] = [];
230
+ for (const file of files) {
231
+ // Folder yang di-drop muncul sebagai File 0 byte tanpa tipe.
232
+ if (file.size === 0) {
233
+ reportError(`"${file.name}" is empty or is a folder.`);
234
+ continue;
235
+ }
236
+ if (file.size > MAX_ATTACHMENT_BYTES) {
237
+ reportError(`"${file.name}" exceeds the 20 MiB limit.`);
238
+ continue;
239
+ }
240
+ picked.push({
241
+ key: crypto.randomUUID(),
242
+ file,
243
+ previewUrl: isPreviewableImage(file.type) ? URL.createObjectURL(file) : null,
244
+ });
245
+ }
246
+ if (picked.length === 0) return;
247
+ setPendingImages((prev) => [...prev, ...picked]);
248
+ if (from) setPlate({ from, keys: picked.map((p) => p.key), targets: null, box: null });
249
+ },
250
+ [reportError],
251
+ );
252
+ useImperativeHandle(ref, () => ({ addFiles }), [addFiles]);
253
+
254
+ // Item plate sudah dirender (tak terlihat) -> ukur posisi akhirnya. Deretan
255
+ // lampiran di-scroll ke item baru dulu agar tujuan animasi terlihat.
256
+ useLayoutEffect(() => {
257
+ if (!plate || plate.targets) return;
258
+ const firstEl = attachmentEls.current.get(plate.keys[0] ?? "");
259
+ firstEl?.scrollIntoView({ block: "nearest", inline: "nearest" });
260
+ const targets = plate.keys.flatMap((key) => {
261
+ const item = pendingImages.find((p) => p.key === key);
262
+ const el = attachmentEls.current.get(key);
263
+ return item && el ? [{ item, rect: el.getBoundingClientRect() }] : [];
264
+ });
265
+ if (targets.length === 0) setPlate(null);
266
+ else setPlate({ ...plate, targets, box: boxRef.current?.getBoundingClientRect() ?? null });
267
+ }, [plate, pendingImages]);
268
+
269
+ const registerAttachmentEl = useCallback((key: string, el: HTMLElement | null) => {
270
+ if (el) attachmentEls.current.set(key, el);
271
+ else attachmentEls.current.delete(key);
272
+ }, []);
273
+ const endPlate = useCallback(() => setPlate(null), []);
274
+
275
+ const removeAttachment = useCallback((key: string) => {
276
+ setPendingImages((prev) => {
277
+ const target = prev.find((p) => p.key === key);
278
+ if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
279
+ return prev.filter((p) => p.key !== key);
280
+ });
281
+ }, []);
282
+
283
+ /**
284
+ * Input dapat dipakai = izin engine (koneksi + running + tidak busy) DAN
285
+ * tidak sedang upload lampiran.
286
+ */
287
+ const canInput = inputAllowed && !sending;
288
+ const canSubmit = canInput && (text.trim() !== "" || pendingImages.length > 0);
289
+
290
+ const submitText = async (event: React.FormEvent<HTMLFormElement>) => {
291
+ event.preventDefault();
292
+ if (!canInput || sending) return;
293
+ if (text.trim() === "" && pendingImages.length === 0) return;
294
+ setSending(true);
295
+ try {
296
+ // Referensi @path dikenal dijadikan part `file` — teks asli tidak
297
+ // diubah (tetap tampil di bubble); part `file` hanya penanda tambahan
298
+ // agar isi file benar-benar dibaca opencode.
299
+ const files = extractMentionedFiles(text, suggestedCacheRef.current);
300
+ // Upload gambar dulu; id lampiran dipakai pesan WS berikutnya.
301
+ const images: string[] = [];
302
+ for (const img of pendingImages) {
303
+ const up = await apiUploadFile(`/api/sessions/${session.id}/uploads`, img.file);
304
+ images.push(up.id);
305
+ }
306
+ send({ type: "input", sessionId: session.id, text: text.trim(), files, images });
307
+ for (const img of pendingImages) if (img.previewUrl) URL.revokeObjectURL(img.previewUrl);
308
+ setPendingImages([]);
309
+ setText("");
310
+ mention.close();
311
+ } catch (e) {
312
+ reportError(e instanceof ApiError ? e.message : apiErrorMessage("ERROR"));
313
+ } finally {
314
+ setSending(false);
315
+ }
316
+ };
317
+
318
+ /** Lampirkan file yang di-paste (screenshot, file dari file manager). */
319
+ const handlePaste = useCallback(
320
+ (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
321
+ const files = [...e.clipboardData.files];
322
+ if (files.length === 0) return;
323
+ // Menyalin file dari file manager juga menaruh path/URI-nya sebagai
324
+ // teks di clipboard — cegah paste bawaan agar path tidak ikut masuk
325
+ // ke input; filenya cukup jadi lampiran.
326
+ e.preventDefault();
327
+ addFiles(files);
328
+ },
329
+ [addFiles],
330
+ );
331
+
332
+ return (
333
+ /* Input bebas. `pb-[env(safe-area-inset-bottom)]` menjaga composer tidak
334
+ tertutup home indicator saat dipasang sebagai PWA di HP. */
335
+ // `overflow-y-hidden` + `scrollbar-gutter-stable`: footer mencadangkan
336
+ // lebar scrollbar yang SAMA dengan area scroll timeline di atasnya,
337
+ // sehingga keduanya punya titik tengah horizontal yang sama. Tanpa ini
338
+ // composer tampak bergeser ke kanan dari kolom percakapan.
339
+ <footer className="shrink-0 scrollbar-gutter-stable overflow-y-hidden px-3 py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] sm:px-4">
340
+ {/* Pemilih model kini jadi judul header — composer fokus ke input saja
341
+ supaya area mengetik di HP tidak terpotong baris tambahan. */}
342
+ {/* Lebar = lebar TEKS percakapan (`CONVERSATION_MAX_W`), bukan kotak
343
+ timeline — tepi kiri-kanan input sejajar dengan isi chat. */}
344
+ <form
345
+ ref={formRef}
346
+ onSubmit={submitText}
347
+ className={cn("relative mx-auto flex w-full flex-col", CONVERSATION_MAX_W)}
348
+ >
349
+ {/* Pengukur lebar teks satu baris — font identik dengan textarea,
350
+ tak terlihat & tanpa wrap (whitespace-pre) sehingga scrollWidth =
351
+ lebar alami teks. Dipakai keputusan stacked yang stabil. */}
352
+ <span
353
+ ref={measureRef}
354
+ aria-hidden
355
+ className="pointer-events-none absolute top-0 invisible whitespace-pre text-base leading-6"
356
+ />
357
+ {/* Composer compact: image picker di kiri, input prompt di tengah,
358
+ lalu mode agent di kanan. Model tetap dipilih dari header. */}
359
+ <div
360
+ ref={boxRef}
361
+ className={cn(
362
+ "rounded-xl border bg-card/80 p-2 shadow-sm transition-colors",
363
+ "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/20",
364
+ !canInput && "opacity-70",
365
+ )}
366
+ >
367
+ {/* Lampiran gambar: di dalam kotak composer, di atas input —
368
+ terbaca sebagai bagian dari pesan yang akan dikirim. */}
369
+ {pendingImages.length > 0 && (
370
+ <PendingAttachments
371
+ items={pendingImages}
372
+ onRemove={removeAttachment}
373
+ hiddenKeys={plate ? new Set(plate.keys) : undefined}
374
+ registerEl={registerAttachmentEl}
375
+ />
376
+ )}
377
+ {plate?.targets && (
378
+ <DropPlate from={plate.from} items={plate.targets} box={plate.box} onDone={endPlate} />
379
+ )}
380
+
381
+ {/* Dropdown saran @file (muncul di atas input saat token @ aktif). */}
382
+ {mention.mention !== null && (mention.suggestions.length > 0 || mention.loading) && (
383
+ <FileMentionDropdown
384
+ loading={mention.loading}
385
+ error={mention.error}
386
+ suggestions={mention.suggestions}
387
+ highlighted={mention.highlighted}
388
+ onPick={(i) => {
389
+ const next = mention.pick(i);
390
+ if (next !== null) applyPicked(next);
391
+ }}
392
+ />
393
+ )}
394
+
395
+ {(() => {
396
+ // Ala Gemini: begitu input tumbuh ke 2 baris (multiline), tombol
397
+ // aksi & kirim/stop pindah ke baris di BAWAH textarea — berlaku
398
+ // di desktop maupun mobile. Satu baris: semuanya dalam satu baris.
399
+ const stacked = multiline;
400
+
401
+ // Mention @file yang dikenali -> latar tipis transparan (tanpa
402
+ // bingkai / garis bawah — garis bawah mirip penanda autocorrect
403
+ // keyboard HP) lewat lapisan di BELAKANG textarea (teks & metrik
404
+ // identik, warna teks transparan; hanya latarnya yang terlihat).
405
+ // Sengaja bukan warna teks: textarea harus tetap menggambar
406
+ // teksnya sendiri agar teks komposisi IME tetap terlihat. Textarea tetap input aslinya: kursor, seleksi,
407
+ // undo, IME, dan autocomplete tidak berubah. Scroll disinkronkan.
408
+ const segments = splitComposerMentions(text, suggestedCacheRef.current);
409
+ const hasMention = segments.some((seg) => seg.kind === "mention");
410
+ const textareaEl = (
411
+ <div
412
+ key="composer-textarea"
413
+ className={cn("relative min-w-0", stacked ? "w-full" : "flex-1 self-center")}
414
+ >
415
+ {hasMention && (
416
+ <div
417
+ ref={highlightRef}
418
+ aria-hidden
419
+ className="pointer-events-none absolute inset-0 overflow-hidden px-2 py-1.5 text-base leading-6 break-words whitespace-pre-wrap text-transparent"
420
+ >
421
+ {segments.map((seg, i) =>
422
+ seg.kind === "mention" ? (
423
+ <mark
424
+ // biome-ignore lint/suspicious/noArrayIndexKey: urutan potongan = urutan teks
425
+ key={i}
426
+ className="rounded-[3px] bg-primary/10 text-transparent dark:bg-primary/15"
427
+ >
428
+ {seg.text}
429
+ </mark>
430
+ ) : (
431
+ // biome-ignore lint/suspicious/noArrayIndexKey: urutan potongan = urutan teks
432
+ <span key={i}>{seg.text}</span>
433
+ ),
434
+ )}
435
+ {/* Baris kosong di akhir tetap punya tinggi (sama seperti textarea). */}
436
+ {"\u200b"}
437
+ </div>
438
+ )}
439
+ <textarea
440
+ ref={textareaRef}
441
+ value={text}
442
+ onChange={(e) => {
443
+ setText(e.target.value);
444
+ mention.onInputChange(
445
+ e.target.value,
446
+ e.target.selectionStart ?? e.target.value.length,
447
+ );
448
+ }}
449
+ onKeyDown={(e) => {
450
+ // Dropdown aktif: panah/enter/tab/escape dikelola autocomplete.
451
+ if (mention.handleKeyDown(e)) return;
452
+ if (e.key === "Enter" && !e.shiftKey) {
453
+ e.preventDefault();
454
+ e.currentTarget.form?.requestSubmit();
455
+ }
456
+ }}
457
+ onPaste={handlePaste}
458
+ onScroll={(e) => {
459
+ if (highlightRef.current) {
460
+ highlightRef.current.scrollTop = e.currentTarget.scrollTop;
461
+ }
462
+ }}
463
+ onBlur={() => mention.close()}
464
+ placeholder={composerPlaceholder({
465
+ busy,
466
+ canInput,
467
+ compact: isMobile,
468
+ hasAttachments: pendingImages.length > 0,
469
+ })}
470
+ aria-label="Free-form input"
471
+ disabled={!canInput}
472
+ rows={1}
473
+ // `relative` = di atas lapisan sorot; latar transparan agar
474
+ // sorotan di belakangnya terlihat.
475
+ // Placeholder selalu satu baris (dipotong "…"), tidak pernah wrap:
476
+ // textarea kosong tidak boleh tumbuh / jadi scrollable hanya
477
+ // karena teks petunjuk (mis. saat lampiran mempersempit ruang).
478
+ className="relative block max-h-40 min-h-9 w-full min-w-0 resize-none border-0 bg-transparent px-2 py-1.5 text-base leading-6 break-words shadow-none outline-none placeholder:truncate placeholder:text-muted-foreground focus-visible:ring-0 disabled:cursor-not-allowed placeholder-shown:overflow-hidden"
479
+ />
480
+ </div>
481
+ );
482
+
483
+ const sendOrStopEl = generating ? (
484
+ // Saat model merespon: tombol kirim berubah jadi tombol Stop
485
+ // (hentikan balasan saja, Session tetap aktif ala opencode).
486
+ <Button
487
+ key="composer-stop"
488
+ type="button"
489
+ variant="outline"
490
+ size="icon"
491
+ onClick={onInterrupt}
492
+ disabled={wsStatus !== "connected"}
493
+ aria-label="Stop response"
494
+ title="Stop the model response"
495
+ className="size-9 shrink-0 border-destructive/60 text-destructive hover:bg-destructive/10 hover:text-destructive"
496
+ >
497
+ <SquareIcon className="size-3.5" />
498
+ </Button>
499
+ ) : (
500
+ // Tombol kirim muncul setelah ada prompt atau lampiran gambar.
501
+ (text.trim() !== "" || pendingImages.length > 0) && (
502
+ <Button
503
+ key="composer-send"
504
+ type="submit"
505
+ size="icon"
506
+ disabled={!canSubmit}
507
+ aria-label="Send message"
508
+ className="size-9 shrink-0"
509
+ >
510
+ {sending ? <Spinner className="size-4" /> : <SendHorizontalIcon />}
511
+ </Button>
512
+ )
513
+ );
514
+
515
+ // Mobile: satu tombol aksi (attach image + agent mode) lewat Sheet,
516
+ // supaya baris composer HP tetap ringkas (3 elemen saja).
517
+ if (isMobile) {
518
+ const actionTrigger = (
519
+ <SheetTrigger asChild key="composer-actions-trigger">
520
+ <Button
521
+ type="button"
522
+ variant="ghost"
523
+ size="icon"
524
+ disabled={!canInput}
525
+ aria-label="More actions"
526
+ title="Attach files or change agent mode"
527
+ className="size-9 shrink-0 text-muted-foreground hover:text-foreground"
528
+ >
529
+ <PlusIcon data-icon="inline-start" />
530
+ </Button>
531
+ </SheetTrigger>
532
+ );
533
+
534
+ return (
535
+ <Sheet open={actionSheetOpen} onOpenChange={setActionSheetOpen}>
536
+ <div className={cn("flex gap-1", stacked ? "flex-col" : "items-center")}>
537
+ {stacked ? (
538
+ <>
539
+ {textareaEl}
540
+ <div className="flex items-center justify-between gap-1">
541
+ {actionTrigger}
542
+ {sendOrStopEl}
543
+ </div>
544
+ </>
545
+ ) : (
546
+ <>
547
+ {actionTrigger}
548
+ {textareaEl}
549
+ {sendOrStopEl}
550
+ </>
551
+ )}
552
+ </div>
553
+ <ComposerActionSheet
554
+ disabled={!canInput}
555
+ onPickImage={() => {
556
+ setActionSheetOpen(false);
557
+ fileInputRef.current?.click();
558
+ }}
559
+ agentPicker={agentPicker}
560
+ activeAgent={agent}
561
+ onPickAgent={(name) => {
562
+ setActionSheetOpen(false);
563
+ void agentPicker.pick(name);
564
+ }}
565
+ />
566
+ </Sheet>
567
+ );
568
+ }
569
+
570
+ // Desktop: attach image, agent mode, dan kirim/stop tetap tampil
571
+ // langsung sebagai kontrol terpisah di toolbar composer. Satu
572
+ // baris: semuanya sejajar; multiline: kontrol turun ke bawah
573
+ // textarea (ala Gemini).
574
+ const attachImageEl = (
575
+ <Button
576
+ type="button"
577
+ variant="ghost"
578
+ size="icon"
579
+ onClick={() => fileInputRef.current?.click()}
580
+ disabled={!canInput}
581
+ aria-label="Attach files"
582
+ title="Attach files (or drop them here, max 20 MiB each)"
583
+ className="size-9 shrink-0 text-muted-foreground hover:text-foreground"
584
+ >
585
+ <PaperclipIcon data-icon="inline-start" />
586
+ </Button>
587
+ );
588
+
589
+ const agentPickerEl = (
590
+ <AgentPicker
591
+ projectId={session.projectId}
592
+ agentType={session.agentType}
593
+ sessionId={session.id}
594
+ agent={agent}
595
+ onChanged={setAgent}
596
+ disabled={!canInput}
597
+ />
598
+ );
599
+
600
+ if (stacked) {
601
+ return (
602
+ <div className="flex flex-col gap-1">
603
+ {textareaEl}
604
+ <div className="flex items-center justify-between gap-1">
605
+ {attachImageEl}
606
+ <div className="flex items-center gap-1">
607
+ {agentPickerEl}
608
+ {sendOrStopEl}
609
+ </div>
610
+ </div>
611
+ </div>
612
+ );
613
+ }
614
+
615
+ return (
616
+ <div className="flex items-center gap-1">
617
+ {attachImageEl}
618
+ {textareaEl}
619
+ {agentPickerEl}
620
+ {sendOrStopEl}
621
+ </div>
622
+ );
623
+ })()}
624
+ </div>
625
+ <input
626
+ ref={fileInputRef}
627
+ type="file"
628
+ multiple
629
+ className="sr-only"
630
+ tabIndex={-1}
631
+ aria-hidden
632
+ onChange={(e) => {
633
+ const files = e.target.files ? [...e.target.files] : [];
634
+ addFiles(files);
635
+ e.target.value = ""; // izinkan memilih file yang sama lagi
636
+ }}
637
+ />
638
+ </form>
639
+ </footer>
640
+ );
641
+ }