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,165 @@
1
+ /**
2
+ * "Plate" animasi drop file: saat file dilepas, kartu "Add anything"
3
+ * berubah menjadi plate berisi tumpukan file; plate lalu MENYUSUT menjadi
4
+ * kotak composer, sementara tiap file terbang ke slot preview-nya masing-
5
+ * masing di atas input.
6
+ *
7
+ * Kenapa terbaca sebagai "geser", bukan "fade":
8
+ * - Setiap file punya perjalanan sendiri: dari tumpukan besar di tengah
9
+ * kartu (skala besar, sedikit miring, bertumpuk) ke slot aslinya (skala 1).
10
+ * Perubahan skala + posisi + rotasi inilah yang dilihat mata sebagai
11
+ * perpindahan. Satu grup yang ditranslasi utuh (versi lama) tidak
12
+ * menyusut bila isinya sudah lebar, jadi hanya tampak bergeser lalu hilang.
13
+ * - Latar plate mendarat tepat di KOTAK COMPOSER (warna & sudut serupa),
14
+ * lalu memudar ke dalamnya — tidak ada kotak besar yang "menghilang" di
15
+ * ruang kosong.
16
+ *
17
+ * FLIP tanpa fitur `layout` Motion (`domAnimation`): composer merender item
18
+ * baru tak terlihat, mengukur rect akhir tiap item + kotak composer, lalu
19
+ * komponen ini merender salinan item di posisi akhir dan menganimasikan
20
+ * transform dari posisi tumpukan menuju identitas. Selesai -> `onDone`
21
+ * memunculkan item asli di frame yang sama (posisinya identik).
22
+ *
23
+ * Portal ke `document.body` (position fixed tidak boleh terjebak ancestor
24
+ * ber-transform). Reduce motion -> dilewati langsung.
25
+ */
26
+ import { useReducedMotion } from "motion/react";
27
+ import * as m from "motion/react-m";
28
+ import { useEffect } from "react";
29
+ import { createPortal } from "react-dom";
30
+ import { AttachmentVisual, type PendingAttachment } from "@/components/sessions/PendingAttachments";
31
+
32
+ export interface DropPlateProps {
33
+ /** Rect kartu overlay saat file dilepas (viewport). */
34
+ from: DOMRect;
35
+ /** Rect akhir tiap item di deretan lampiran (viewport). */
36
+ items: { item: PendingAttachment; rect: DOMRect }[];
37
+ /** Rect kotak composer; null -> plate menyusut ke gabungan item. */
38
+ box: DOMRect | null;
39
+ onDone: () => void;
40
+ }
41
+
42
+ /** Rect gabungan beberapa rect. */
43
+ function unionRect(rects: DOMRect[]): { x: number; y: number; w: number; h: number } {
44
+ const x = Math.min(...rects.map((r) => r.left));
45
+ const y = Math.min(...rects.map((r) => r.top));
46
+ const r = Math.max(...rects.map((q) => q.right));
47
+ const b = Math.max(...rects.map((q) => q.bottom));
48
+ return { x, y, w: r - x, h: b - y };
49
+ }
50
+
51
+ /** Jeda sebelum terbang: file terlihat "masuk" ke plate (detik). */
52
+ const HOLD = 0.12;
53
+ /** Durasi visual terbang (detik). */
54
+ const FLY = 0.55;
55
+ /** Ukuran tumpukan di tengah kartu: sisi terpanjang item = porsi kartu ini. */
56
+ const PILE_FILL = 0.42;
57
+ /** Maksimal item yang tampak di tumpukan; sisanya menumpuk di belakang. */
58
+ const PILE_SPREAD = 3;
59
+ /** Jeda antar file saat terbang (detik) & batasnya. */
60
+ const STAGGER = 0.035;
61
+ const MAX_STAGGER = 0.12;
62
+
63
+ export function DropPlate({ from, items, box, onDone }: DropPlateProps) {
64
+ const reduce = useReducedMotion();
65
+
66
+ useEffect(() => {
67
+ if (reduce) onDone();
68
+ }, [reduce, onDone]);
69
+ // Selesai berdasarkan waktu, bukan `onAnimationComplete`: spring baru
70
+ // "diam" ±0.5 detik setelah gerakannya tak lagi terlihat, dan selama itu
71
+ // preview asli masih tersembunyi (tidak bisa diklik / dihapus). Salinan
72
+ // sudah berada di posisi akhir saat ini, jadi pergantiannya tak terlihat.
73
+ // Sekaligus jaring pengaman bila animasi dibatalkan (tab di latar).
74
+ const endMs = (HOLD + Math.min((items.length - 1) * STAGGER, MAX_STAGGER) + FLY) * 1000 + 60;
75
+ useEffect(() => {
76
+ const id = setTimeout(onDone, endMs);
77
+ return () => clearTimeout(id);
78
+ }, [onDone, endMs]);
79
+ if (reduce || items.length === 0) return null;
80
+
81
+ const cx = from.left + from.width / 2;
82
+ const cy = from.top + from.height / 2;
83
+ const land = box
84
+ ? { x: box.left, y: box.top, w: box.width, h: box.height }
85
+ : unionRect(items.map((i) => i.rect));
86
+
87
+ return createPortal(
88
+ <div aria-hidden className="pointer-events-none fixed inset-0 z-50">
89
+ {/* Latar plate: kartu -> kotak composer, lalu larut ke dalamnya. */}
90
+ <m.div
91
+ className="absolute top-0 left-0 border bg-popover shadow-2xl will-change-transform"
92
+ initial={{
93
+ x: from.left,
94
+ y: from.top,
95
+ width: from.width,
96
+ height: from.height,
97
+ borderRadius: 28,
98
+ opacity: 1,
99
+ }}
100
+ animate={{
101
+ x: land.x,
102
+ y: land.y,
103
+ width: land.w,
104
+ height: land.h,
105
+ borderRadius: 12,
106
+ opacity: 0,
107
+ }}
108
+ transition={{
109
+ default: { type: "spring", visualDuration: FLY, bounce: 0, delay: HOLD },
110
+ // Memudar hanya di ekor perjalanan: saat itu ukurannya sudah
111
+ // sama dengan kotak composer, jadi yang terlihat adalah plate
112
+ // "menjadi" composer, bukan kotak yang lenyap.
113
+ opacity: { duration: 0.18, delay: HOLD + FLY * 0.8, ease: "easeOut" },
114
+ }}
115
+ />
116
+
117
+ {/* Tiap file: dari tumpukan di tengah kartu ke slot preview-nya. */}
118
+ {items.map(({ item, rect }, i) => {
119
+ // Skala tumpukan: item terlihat besar di kartu, tapi tidak lebih
120
+ // besar dari porsi kartu.
121
+ const pileScale = Math.min(
122
+ 2.6,
123
+ Math.max(
124
+ 1.3,
125
+ (Math.min(from.width, from.height) * PILE_FILL) / Math.max(rect.width, rect.height),
126
+ ),
127
+ );
128
+ // Posisi dalam tumpukan: sedikit menyebar & miring bergantian.
129
+ const slot = Math.min(i, PILE_SPREAD - 1) - (Math.min(items.length, PILE_SPREAD) - 1) / 2;
130
+ const offsetX = slot * 26;
131
+ const offsetY = Math.abs(slot) * 6;
132
+ const rotate = slot * 7;
133
+ // Transform relatif ke posisi akhir (pusat item -> pusat tumpukan).
134
+ const dx = cx + offsetX - (rect.left + rect.width / 2);
135
+ const dy = cy + offsetY - (rect.top + rect.height / 2) - from.height * 0.06;
136
+ const delay = HOLD + Math.min(i * STAGGER, MAX_STAGGER);
137
+ return (
138
+ <m.div
139
+ key={item.key}
140
+ className="absolute will-change-transform"
141
+ style={{
142
+ left: rect.left,
143
+ top: rect.top,
144
+ width: rect.width,
145
+ height: rect.height,
146
+ zIndex: items.length - i,
147
+ }}
148
+ initial={{ x: dx, y: dy, scale: pileScale, rotate, opacity: 0 }}
149
+ animate={{ x: 0, y: 0, scale: 1, rotate: 0, opacity: 1 }}
150
+ transition={{
151
+ default: { type: "spring", visualDuration: FLY, bounce: 0.12, delay },
152
+ // Muncul cepat di tumpukan (pop), lalu tetap solid sepanjang jalan.
153
+ opacity: { duration: 0.1 },
154
+ }}
155
+ >
156
+ <div className="size-full shadow-lg [&>*]:shadow-none" style={{ borderRadius: 10 }}>
157
+ <AttachmentVisual item={item} />
158
+ </div>
159
+ </m.div>
160
+ );
161
+ })}
162
+ </div>,
163
+ document.body,
164
+ );
165
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Overlay drag & drop file untuk layar Session (ala "Add anything").
3
+ *
4
+ * - Aktif hanya saat yang di-drag berisi file dari luar halaman (bukan teks
5
+ * / gambar yang di-drag di dalam aplikasi).
6
+ * - Seluruh layar diredupkan (termasuk sidebar) lewat portal `fixed` ke
7
+ * `document.body`; kartu naik dari bawah dan berhenti tepat di tengah
8
+ * viewport: ilustrasi tumpukan file, judul, dan keterangan. Tidak bisa
9
+ * menerima (Session mati / model sedang merespon) -> kartu memberi tahu
10
+ * alasannya, drop diabaikan.
11
+ * - Saat file dilepas, `onDropFiles` menerima file + posisi kartu. Kartu
12
+ * langsung menghilang tanpa animasi keluar karena "plate" di composer
13
+ * (`DropPlate`) mengambil alih dari posisi yang sama lalu mengecil ke
14
+ * deretan lampiran.
15
+ *
16
+ * Listener dipasang di `window` (drop di mana pun selama layar Session
17
+ * terbuka) dan `preventDefault` selalu dipanggil untuk drag file agar
18
+ * browser tidak membuka file di tab ini.
19
+ */
20
+ import { FileTextIcon, ImageIcon, SquareTerminalIcon } from "lucide-react";
21
+ import { AnimatePresence } from "motion/react";
22
+ import * as m from "motion/react-m";
23
+ import { useEffect, useRef, useState } from "react";
24
+ import { createPortal } from "react-dom";
25
+ import { cn } from "@/lib/utils";
26
+
27
+ function hasFiles(e: DragEvent): boolean {
28
+ return e.dataTransfer?.types.includes("Files") ?? false;
29
+ }
30
+
31
+ export interface FileDropOverlayProps {
32
+ /** Composer bisa menerima lampiran sekarang. */
33
+ accepting: boolean;
34
+ /** Keterangan saat tidak bisa menerima (mis. "Session is not active"). */
35
+ blockedReason?: string;
36
+ onDropFiles: (files: File[], from: DOMRect | null) => void;
37
+ }
38
+
39
+ type Phase = "idle" | "dragging" | "dropped";
40
+
41
+ export function FileDropOverlay({ accepting, blockedReason, onDropFiles }: FileDropOverlayProps) {
42
+ const [phase, setPhase] = useState<Phase>("idle");
43
+ const cardRef = useRef<HTMLDivElement | null>(null);
44
+ // Nilai terbaru untuk listener window (dipasang sekali).
45
+ const latest = useRef({ accepting, onDropFiles });
46
+ latest.current = { accepting, onDropFiles };
47
+
48
+ useEffect(() => {
49
+ /** dragenter/dragleave berpasangan per elemen — hitung kedalaman. */
50
+ let depth = 0;
51
+ /** Drag dimulai di dalam halaman (bukan file dari luar) -> abaikan. */
52
+ let internal = false;
53
+
54
+ const onDragStart = () => {
55
+ internal = true;
56
+ };
57
+ const onDragEnd = () => {
58
+ internal = false;
59
+ };
60
+ const onEnter = (e: DragEvent) => {
61
+ if (internal || !hasFiles(e)) return;
62
+ depth++;
63
+ setPhase("dragging");
64
+ };
65
+ const onOver = (e: DragEvent) => {
66
+ if (internal || !hasFiles(e)) return;
67
+ e.preventDefault();
68
+ if (e.dataTransfer) e.dataTransfer.dropEffect = latest.current.accepting ? "copy" : "none";
69
+ };
70
+ const onLeave = (e: DragEvent) => {
71
+ if (internal || !hasFiles(e)) return;
72
+ depth = Math.max(0, depth - 1);
73
+ if (depth === 0) setPhase("idle");
74
+ };
75
+ const onDrop = (e: DragEvent) => {
76
+ if (internal || !hasFiles(e)) return;
77
+ e.preventDefault();
78
+ depth = 0;
79
+ const files = e.dataTransfer ? [...e.dataTransfer.files] : [];
80
+ if (files.length === 0 || !latest.current.accepting) {
81
+ setPhase("idle");
82
+ return;
83
+ }
84
+ const rect = cardRef.current?.getBoundingClientRect() ?? null;
85
+ setPhase("dropped");
86
+ latest.current.onDropFiles(files, rect);
87
+ };
88
+ // Jendela kehilangan fokus / Esc membatalkan drag -> tutup overlay.
89
+ const reset = () => {
90
+ depth = 0;
91
+ internal = false;
92
+ setPhase("idle");
93
+ };
94
+
95
+ window.addEventListener("dragstart", onDragStart);
96
+ window.addEventListener("dragend", onDragEnd);
97
+ window.addEventListener("dragenter", onEnter);
98
+ window.addEventListener("dragover", onOver);
99
+ window.addEventListener("dragleave", onLeave);
100
+ window.addEventListener("drop", onDrop);
101
+ window.addEventListener("blur", reset);
102
+ return () => {
103
+ window.removeEventListener("dragstart", onDragStart);
104
+ window.removeEventListener("dragend", onDragEnd);
105
+ window.removeEventListener("dragenter", onEnter);
106
+ window.removeEventListener("dragover", onOver);
107
+ window.removeEventListener("dragleave", onLeave);
108
+ window.removeEventListener("drop", onDrop);
109
+ window.removeEventListener("blur", reset);
110
+ };
111
+ }, []);
112
+
113
+ // "dropped" hanya perlu satu frame agar kartu keluar tanpa animasi.
114
+ useEffect(() => {
115
+ if (phase !== "dropped") return;
116
+ const id = requestAnimationFrame(() => setPhase("idle"));
117
+ return () => cancelAnimationFrame(id);
118
+ }, [phase]);
119
+
120
+ const dropped = phase === "dropped";
121
+
122
+ return createPortal(
123
+ <AnimatePresence custom={dropped}>
124
+ {phase === "dragging" && (
125
+ <m.div
126
+ key="file-drop-overlay"
127
+ aria-hidden
128
+ initial={{ opacity: 0 }}
129
+ animate={{ opacity: 1 }}
130
+ exit={{ opacity: 0, transition: { duration: 0.2 } }}
131
+ transition={{ duration: 0.15 }}
132
+ // Tidak menangkap pointer: dragenter/leave tetap dari elemen asli
133
+ // sehingga hitungan kedalaman tidak kacau oleh overlay sendiri.
134
+ // `fixed` + portal: menutupi seluruh viewport (sidebar ikut redup)
135
+ // dan kartu terpusat di tengah layar, bukan di tengah area chat.
136
+ className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center bg-background/70 p-6 backdrop-blur-[2px]"
137
+ >
138
+ <m.div
139
+ ref={cardRef}
140
+ custom={dropped}
141
+ // Naik dari bawah layar lalu mengendap di tengah.
142
+ initial={{ opacity: 0, y: 120, scale: 0.96 }}
143
+ animate={{ opacity: 1, y: 0, scale: 1 }}
144
+ variants={{
145
+ // Dilepas: plate mengambil alih di posisi sama -> hilang instan.
146
+ // Batal (drag keluar jendela): turun kembali ke bawah.
147
+ exit: (wasDropped: boolean) =>
148
+ wasDropped
149
+ ? { opacity: 0, transition: { duration: 0 } }
150
+ : {
151
+ opacity: 0,
152
+ y: 72,
153
+ scale: 0.97,
154
+ transition: { duration: 0.18, ease: "easeIn" },
155
+ },
156
+ }}
157
+ exit="exit"
158
+ transition={{
159
+ type: "spring",
160
+ stiffness: 340,
161
+ damping: 30,
162
+ mass: 0.9,
163
+ opacity: { duration: 0.18 },
164
+ }}
165
+ className={cn(
166
+ "flex aspect-[5/4] w-full max-w-md flex-col items-center justify-center gap-5 rounded-[28px] border bg-popover px-8 text-center shadow-2xl",
167
+ !accepting && "opacity-90",
168
+ )}
169
+ >
170
+ <FileStack muted={!accepting} />
171
+ <div className="flex flex-col gap-1.5">
172
+ <p className="text-xl font-semibold tracking-tight">
173
+ {accepting ? "Add anything" : "Can't add files right now"}
174
+ </p>
175
+ <p className="text-[14px] text-muted-foreground">
176
+ {accepting
177
+ ? "Drop any file here to add it to the conversation"
178
+ : (blockedReason ?? "Try again when the session is ready.")}
179
+ </p>
180
+ </div>
181
+ {accepting && (
182
+ <span className="rounded-full bg-muted px-2.5 py-0.5 text-[12px] text-muted-foreground">
183
+ Up to 20 MB per file
184
+ </span>
185
+ )}
186
+ </m.div>
187
+ </m.div>
188
+ )}
189
+ </AnimatePresence>,
190
+ document.body,
191
+ );
192
+ }
193
+
194
+ /** Ilustrasi tiga kartu file (terminal, dokumen, gambar) yang bertumpuk. */
195
+ function FileStack({ muted }: { muted: boolean }) {
196
+ const spring = { type: "spring", stiffness: 380, damping: 22 } as const;
197
+ const tile =
198
+ "absolute flex size-14 items-center justify-center rounded-2xl shadow-lg ring-1 ring-black/5";
199
+ return (
200
+ <div className={cn("relative h-20 w-36", muted && "grayscale")}>
201
+ <m.span
202
+ initial={{ opacity: 0, x: 18, rotate: 0 }}
203
+ animate={{ opacity: 1, x: 0, rotate: -14 }}
204
+ transition={{ ...spring, delay: 0.05 }}
205
+ className={cn(tile, "top-2 left-3 bg-indigo-200 text-indigo-900")}
206
+ >
207
+ <SquareTerminalIcon className="size-7" strokeWidth={2.25} />
208
+ </m.span>
209
+ <m.span
210
+ initial={{ opacity: 0, x: -18, rotate: 0 }}
211
+ animate={{ opacity: 1, x: 0, rotate: 12 }}
212
+ transition={{ ...spring, delay: 0.08 }}
213
+ className={cn(tile, "top-0 right-3 bg-indigo-400 text-white")}
214
+ >
215
+ <FileTextIcon className="size-7" strokeWidth={2.25} />
216
+ </m.span>
217
+ <m.span
218
+ initial={{ opacity: 0, y: 14, scale: 0.9 }}
219
+ animate={{ opacity: 1, y: [0, -4, 0], scale: 1 }}
220
+ transition={{
221
+ opacity: { duration: 0.15 },
222
+ scale: spring,
223
+ // Melayang pelan selama drag berlangsung.
224
+ y: { duration: 2.4, repeat: Number.POSITIVE_INFINITY, ease: "easeInOut", delay: 0.3 },
225
+ }}
226
+ className={cn(tile, "top-5 left-1/2 -ml-7 size-16 bg-indigo-600 text-white")}
227
+ >
228
+ <ImageIcon className="size-8" strokeWidth={2.25} />
229
+ </m.span>
230
+ </div>
231
+ );
232
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Dropdown saran autocomplete `@file` (muncul di atas input saat token @
3
+ * aktif). Pemilihan lewat mousedown (bukan click) agar textarea tidak blur
4
+ * duluan — logika state autocomplete tetap di `useFileMention`.
5
+ */
6
+ export interface FileMentionDropdownProps {
7
+ loading: boolean;
8
+ error: string | null;
9
+ suggestions: string[];
10
+ /** Indeks saran yang disorot (keyboard). */
11
+ highlighted: number;
12
+ /** Pilih saran ke-i (pemanggil menangani pemasukan teks + fokus). */
13
+ onPick: (index: number) => void;
14
+ }
15
+
16
+ export function FileMentionDropdown({
17
+ loading,
18
+ error,
19
+ suggestions,
20
+ highlighted,
21
+ onPick,
22
+ }: FileMentionDropdownProps) {
23
+ return (
24
+ <div className="absolute bottom-full left-0 right-0 z-10 mb-2 overflow-hidden rounded-xl border bg-popover shadow-md">
25
+ <div className="max-h-56 overflow-y-auto">
26
+ {loading && <div className="px-3 py-2 text-xs text-muted-foreground">Searching files…</div>}
27
+ {!loading && error && <div className="px-3 py-2 text-xs text-destructive">{error}</div>}
28
+ {suggestions.map((file, i) => (
29
+ <button
30
+ key={file}
31
+ type="button"
32
+ className={`block w-full truncate px-3 py-2 text-left font-mono text-base ${
33
+ i === highlighted ? "bg-accent text-accent-foreground" : ""
34
+ }`}
35
+ onMouseDown={(e) => {
36
+ // mousedown (bukan click) agar textarea tidak blur duluan.
37
+ e.preventDefault();
38
+ onPick(i);
39
+ }}
40
+ >
41
+ {file}
42
+ </button>
43
+ ))}
44
+ </div>
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Lightbox gambar — pratinjau layar penuh untuk lampiran (composer & pesan).
3
+ *
4
+ * - Dibangun di atas Radix Dialog (fokus terkunci, Esc menutup, klik latar
5
+ * menutup, `aria-modal`).
6
+ * - Beberapa gambar: tombol ‹ › + panah kiri/kanan keyboard, penghitung
7
+ * "2 / 3", dan geser (swipe) horizontal di layar sentuh.
8
+ * - Aksi: buka gambar asli di tab baru (hanya untuk URL non-blob — object URL
9
+ * lokal tidak berguna di tab lain).
10
+ */
11
+ import { ChevronLeftIcon, ChevronRightIcon, ExternalLinkIcon, XIcon } from "lucide-react";
12
+ import { Dialog as DialogPrimitive } from "radix-ui";
13
+ import { useEffect, useRef } from "react";
14
+ import { cn } from "@/lib/utils";
15
+
16
+ export interface LightboxImage {
17
+ src: string;
18
+ alt: string;
19
+ }
20
+
21
+ export interface ImageLightboxProps {
22
+ images: LightboxImage[];
23
+ /** Indeks gambar yang tampil; null = tertutup. */
24
+ index: number | null;
25
+ onIndexChange: (index: number | null) => void;
26
+ }
27
+
28
+ /** Jarak geser minimum (px) agar dianggap swipe. */
29
+ const SWIPE_MIN = 50;
30
+
31
+ export function ImageLightbox({ images, index, onIndexChange }: ImageLightboxProps) {
32
+ const open = index !== null && images[index] !== undefined;
33
+ const current = open ? images[index] : undefined;
34
+ const many = images.length > 1;
35
+ const touchX = useRef<number | null>(null);
36
+
37
+ const go = (delta: number) => {
38
+ if (index === null || !many) return;
39
+ onIndexChange((index + delta + images.length) % images.length);
40
+ };
41
+
42
+ // Panah kiri/kanan untuk berpindah gambar.
43
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `go` dibuat ulang tiap render
44
+ useEffect(() => {
45
+ if (!open || !many) return;
46
+ const onKey = (e: KeyboardEvent) => {
47
+ if (e.key === "ArrowLeft") {
48
+ e.preventDefault();
49
+ go(-1);
50
+ } else if (e.key === "ArrowRight") {
51
+ e.preventDefault();
52
+ go(1);
53
+ }
54
+ };
55
+ window.addEventListener("keydown", onKey);
56
+ return () => window.removeEventListener("keydown", onKey);
57
+ }, [open, many, index, images.length]);
58
+
59
+ const canOpenOriginal = current !== undefined && !current.src.startsWith("blob:");
60
+ const iconBtn =
61
+ "flex size-10 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur transition-colors outline-none hover:bg-white/20 focus-visible:ring-[3px] focus-visible:ring-white/50";
62
+
63
+ return (
64
+ <DialogPrimitive.Root open={open} onOpenChange={(o) => !o && onIndexChange(null)}>
65
+ <DialogPrimitive.Portal>
66
+ <DialogPrimitive.Overlay className="fixed inset-0 z-[110] bg-black/90 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
67
+ <DialogPrimitive.Content
68
+ aria-describedby={undefined}
69
+ className="fixed inset-0 z-[110] flex flex-col outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
70
+ onTouchStart={(e) => {
71
+ touchX.current = e.touches[0]?.clientX ?? null;
72
+ }}
73
+ onTouchEnd={(e) => {
74
+ const start = touchX.current;
75
+ const end = e.changedTouches[0]?.clientX;
76
+ touchX.current = null;
77
+ if (start === null || end === undefined) return;
78
+ if (Math.abs(end - start) >= SWIPE_MIN) go(end < start ? 1 : -1);
79
+ }}
80
+ >
81
+ {/* Bar atas: nama file + penghitung, aksi di kanan. */}
82
+ <div className="flex shrink-0 items-center gap-3 px-3 pt-[max(0.75rem,env(safe-area-inset-top))] pb-2 text-white sm:px-4">
83
+ <DialogPrimitive.Title className="min-w-0 flex-1 truncate text-sm font-medium">
84
+ {current?.alt ?? "Image"}
85
+ {many && index !== null && (
86
+ <span className="ml-2 font-normal tabular-nums text-white/60">
87
+ {index + 1} / {images.length}
88
+ </span>
89
+ )}
90
+ </DialogPrimitive.Title>
91
+ {canOpenOriginal && current && (
92
+ <a
93
+ href={current.src}
94
+ target="_blank"
95
+ rel="noopener noreferrer"
96
+ aria-label="Open original in new tab"
97
+ title="Open original"
98
+ className={iconBtn}
99
+ >
100
+ <ExternalLinkIcon className="size-[18px]" aria-hidden />
101
+ </a>
102
+ )}
103
+ <DialogPrimitive.Close aria-label="Close preview" className={iconBtn}>
104
+ <XIcon className="size-5" aria-hidden />
105
+ </DialogPrimitive.Close>
106
+ </div>
107
+
108
+ {/* Area gambar: klik di luar gambar menutup lightbox. */}
109
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: Esc & tombol Close menangani keyboard */}
110
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: klik latar = tutup (pola lightbox) */}
111
+ <div
112
+ className="relative flex min-h-0 flex-1 items-center justify-center px-3 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-16"
113
+ onClick={(e) => {
114
+ if (e.target === e.currentTarget) onIndexChange(null);
115
+ }}
116
+ >
117
+ {current && (
118
+ <img
119
+ key={current.src}
120
+ src={current.src}
121
+ alt={current.alt}
122
+ className="max-h-full max-w-full rounded-lg object-contain shadow-2xl animate-in fade-in-0 zoom-in-95 duration-200 select-none"
123
+ draggable={false}
124
+ />
125
+ )}
126
+
127
+ {many && (
128
+ <>
129
+ <button
130
+ type="button"
131
+ onClick={() => go(-1)}
132
+ aria-label="Previous image"
133
+ className={cn(iconBtn, "absolute top-1/2 left-3 hidden -translate-y-1/2 sm:flex")}
134
+ >
135
+ <ChevronLeftIcon className="size-5" aria-hidden />
136
+ </button>
137
+ <button
138
+ type="button"
139
+ onClick={() => go(1)}
140
+ aria-label="Next image"
141
+ className={cn(
142
+ iconBtn,
143
+ "absolute top-1/2 right-3 hidden -translate-y-1/2 sm:flex",
144
+ )}
145
+ >
146
+ <ChevronRightIcon className="size-5" aria-hidden />
147
+ </button>
148
+ </>
149
+ )}
150
+ </div>
151
+
152
+ {/* Titik posisi di HP (tombol ‹ › disembunyikan; pakai swipe). */}
153
+ {many && index !== null && (
154
+ <div
155
+ className="flex shrink-0 justify-center gap-1.5 pb-[max(1rem,env(safe-area-inset-bottom))] sm:hidden"
156
+ aria-hidden
157
+ >
158
+ {images.map((img, i) => (
159
+ <span
160
+ key={img.src}
161
+ className={cn(
162
+ "size-1.5 rounded-full transition-colors",
163
+ i === index ? "bg-white" : "bg-white/30",
164
+ )}
165
+ />
166
+ ))}
167
+ </div>
168
+ )}
169
+ </DialogPrimitive.Content>
170
+ </DialogPrimitive.Portal>
171
+ </DialogPrimitive.Root>
172
+ );
173
+ }