kcg-code 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/README.md +228 -2
  2. package/bin/kcgcode.ts +13 -0
  3. package/bun-env.d.ts +19 -0
  4. package/kcg-code.config.example.json +3 -0
  5. package/package.json +83 -4
  6. package/public/logo.svg +1 -0
  7. package/public/manifest.json +18 -0
  8. package/public/sw.js +49 -0
  9. package/src/App.tsx +39 -0
  10. package/src/__tests__/manifest.test.ts +61 -0
  11. package/src/build-config.ts +9 -0
  12. package/src/cli/__tests__/dashboard.test.ts +62 -0
  13. package/src/cli/args.ts +123 -0
  14. package/src/cli/commands.ts +174 -0
  15. package/src/cli/dashboard.tsx +343 -0
  16. package/src/cli/index.ts +37 -0
  17. package/src/cli/theme.ts +100 -0
  18. package/src/components/auth/AuthGate.tsx +182 -0
  19. package/src/components/auth/AvatarEditor.tsx +236 -0
  20. package/src/components/auth/LockScreen.tsx +714 -0
  21. package/src/components/auth/ProfileAvatar.tsx +60 -0
  22. package/src/components/auth/WelcomeScreen.tsx +672 -0
  23. package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
  24. package/src/components/auth/avatar-icons.tsx +114 -0
  25. package/src/components/layout/AppShell.tsx +142 -0
  26. package/src/components/layout/AppSidebar.tsx +419 -0
  27. package/src/components/layout/SearchDialog.tsx +189 -0
  28. package/src/components/layout/SidebarAccount.tsx +111 -0
  29. package/src/components/layout/SidebarSessionMenu.tsx +163 -0
  30. package/src/components/layout/ThemeToggle.tsx +67 -0
  31. package/src/components/projects/FolderBrowser.tsx +142 -0
  32. package/src/components/projects/HomeChat.tsx +250 -0
  33. package/src/components/projects/NewProjectDialog.tsx +249 -0
  34. package/src/components/projects/PanelSection.tsx +102 -0
  35. package/src/components/projects/ProjectInstructions.tsx +229 -0
  36. package/src/components/projects/ProjectRow.tsx +58 -0
  37. package/src/components/projects/ProjectSidePanel.tsx +448 -0
  38. package/src/components/sessions/AgentModeList.tsx +265 -0
  39. package/src/components/sessions/AgentPicker.tsx +123 -0
  40. package/src/components/sessions/AssistantTurn.tsx +145 -0
  41. package/src/components/sessions/AttachmentTile.tsx +161 -0
  42. package/src/components/sessions/CodeBlock.tsx +107 -0
  43. package/src/components/sessions/ComposerActionSheet.tsx +96 -0
  44. package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
  45. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +72 -0
  46. package/src/components/sessions/ContextPanel.tsx +561 -0
  47. package/src/components/sessions/DropPlate.tsx +165 -0
  48. package/src/components/sessions/FileDropOverlay.tsx +232 -0
  49. package/src/components/sessions/FileMentionDropdown.tsx +47 -0
  50. package/src/components/sessions/ImageLightbox.tsx +173 -0
  51. package/src/components/sessions/MarkdownContent.tsx +187 -0
  52. package/src/components/sessions/Mascot.tsx +47 -0
  53. package/src/components/sessions/MessageActions.tsx +60 -0
  54. package/src/components/sessions/MessageRail.tsx +121 -0
  55. package/src/components/sessions/ModelPicker.tsx +209 -0
  56. package/src/components/sessions/ModelSearchList.tsx +151 -0
  57. package/src/components/sessions/NewSessionDialog.tsx +189 -0
  58. package/src/components/sessions/PendingAttachments.tsx +127 -0
  59. package/src/components/sessions/PromptCard.tsx +1085 -0
  60. package/src/components/sessions/PromptPanel.tsx +112 -0
  61. package/src/components/sessions/SessionComposer.tsx +640 -0
  62. package/src/components/sessions/SessionHeader.tsx +175 -0
  63. package/src/components/sessions/SessionList.tsx +396 -0
  64. package/src/components/sessions/SessionRow.tsx +137 -0
  65. package/src/components/sessions/SessionTimeline.tsx +275 -0
  66. package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
  67. package/src/components/sessions/SessionView.tsx +236 -0
  68. package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
  69. package/src/components/sessions/UserMessage.tsx +177 -0
  70. package/src/components/sessions/__tests__/ContextPanel.test.tsx +220 -0
  71. package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
  72. package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
  73. package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
  74. package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
  75. package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
  76. package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
  77. package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
  78. package/src/components/settings/AboutGroup.tsx +170 -0
  79. package/src/components/settings/AccountGroup.tsx +122 -0
  80. package/src/components/settings/DevicesGroup.tsx +151 -0
  81. package/src/components/settings/LhrRemoteAccess.tsx +262 -0
  82. package/src/components/settings/PasskeysGroup.tsx +551 -0
  83. package/src/components/settings/RemoteAccessGroup.tsx +1226 -0
  84. package/src/components/settings/SecurityGroup.tsx +566 -0
  85. package/src/components/settings/WorkspaceGroup.tsx +370 -0
  86. package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
  87. package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
  88. package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
  89. package/src/components/settings/prefs.tsx +265 -0
  90. package/src/components/skills/InstallNotifications.tsx +325 -0
  91. package/src/components/skills/InstallSkillDialog.tsx +233 -0
  92. package/src/components/skills/SkillCards.tsx +210 -0
  93. package/src/components/skills/SkillsProjectPicker.tsx +178 -0
  94. package/src/components/ui/alert-dialog.tsx +179 -0
  95. package/src/components/ui/alert.tsx +60 -0
  96. package/src/components/ui/badge.tsx +46 -0
  97. package/src/components/ui/bubble.tsx +125 -0
  98. package/src/components/ui/button.tsx +62 -0
  99. package/src/components/ui/card.tsx +75 -0
  100. package/src/components/ui/collapsible.tsx +19 -0
  101. package/src/components/ui/dialog.tsx +142 -0
  102. package/src/components/ui/dropdown-menu.tsx +225 -0
  103. package/src/components/ui/empty.tsx +94 -0
  104. package/src/components/ui/field.tsx +234 -0
  105. package/src/components/ui/input-group.tsx +161 -0
  106. package/src/components/ui/input.tsx +21 -0
  107. package/src/components/ui/label.tsx +19 -0
  108. package/src/components/ui/message-scroller.tsx +131 -0
  109. package/src/components/ui/message.tsx +85 -0
  110. package/src/components/ui/popover.tsx +46 -0
  111. package/src/components/ui/select.tsx +172 -0
  112. package/src/components/ui/separator.tsx +28 -0
  113. package/src/components/ui/sheet.tsx +138 -0
  114. package/src/components/ui/sidebar.tsx +699 -0
  115. package/src/components/ui/skeleton.tsx +15 -0
  116. package/src/components/ui/sonner.tsx +45 -0
  117. package/src/components/ui/spinner.tsx +16 -0
  118. package/src/components/ui/switch.tsx +32 -0
  119. package/src/components/ui/textarea.tsx +18 -0
  120. package/src/components/ui/tooltip.tsx +51 -0
  121. package/src/config.ts +85 -0
  122. package/src/db.ts +146 -0
  123. package/src/frontend.tsx +51 -0
  124. package/src/hooks/__tests__/useMention.test.ts +79 -0
  125. package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
  126. package/src/hooks/__tests__/useRouter.test.ts +17 -0
  127. package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
  128. package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
  129. package/src/hooks/__tests__/useTheme.test.ts +118 -0
  130. package/src/hooks/use-mobile.ts +16 -0
  131. package/src/hooks/useAgentPicker.ts +97 -0
  132. package/src/hooks/useMediaQuery.ts +49 -0
  133. package/src/hooks/useMention.ts +236 -0
  134. package/src/hooks/useProjects.ts +88 -0
  135. package/src/hooks/useResyncOnReconnect.ts +28 -0
  136. package/src/hooks/useRouter.ts +92 -0
  137. package/src/hooks/useSessionChat.ts +427 -0
  138. package/src/hooks/useSessionContext.ts +106 -0
  139. package/src/hooks/useSessionList.ts +234 -0
  140. package/src/hooks/useSidebarData.ts +108 -0
  141. package/src/hooks/useSmoothText.ts +122 -0
  142. package/src/hooks/useTheme.ts +164 -0
  143. package/src/hooks/useWebSocket.ts +196 -0
  144. package/src/index.css +27 -0
  145. package/src/index.html +14 -0
  146. package/src/index.ts +49 -0
  147. package/src/lib/__tests__/attachments.test.ts +58 -0
  148. package/src/lib/__tests__/avatar-presets.test.ts +25 -0
  149. package/src/lib/__tests__/collapsible.test.ts +101 -0
  150. package/src/lib/__tests__/composer.test.ts +118 -0
  151. package/src/lib/__tests__/highlight.test.ts +42 -0
  152. package/src/lib/__tests__/home-chat.test.ts +64 -0
  153. package/src/lib/__tests__/lock-screen.test.ts +64 -0
  154. package/src/lib/__tests__/model-picker.test.ts +205 -0
  155. package/src/lib/__tests__/onboarding.test.ts +41 -0
  156. package/src/lib/__tests__/permission-info.test.ts +61 -0
  157. package/src/lib/__tests__/project-extensions.test.ts +52 -0
  158. package/src/lib/__tests__/project-overview.test.ts +173 -0
  159. package/src/lib/__tests__/references.test.ts +121 -0
  160. package/src/lib/__tests__/routes.test.ts +134 -0
  161. package/src/lib/__tests__/session-summary.test.ts +115 -0
  162. package/src/lib/__tests__/session-title.test.ts +58 -0
  163. package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
  164. package/src/lib/__tests__/skill-installs.test.ts +134 -0
  165. package/src/lib/__tests__/skills-catalog.test.ts +96 -0
  166. package/src/lib/__tests__/tone.test.ts +37 -0
  167. package/src/lib/__tests__/turns-delta.test.ts +55 -0
  168. package/src/lib/api.ts +306 -0
  169. package/src/lib/attachments.ts +203 -0
  170. package/src/lib/auth.ts +92 -0
  171. package/src/lib/avatar-presets.ts +31 -0
  172. package/src/lib/collapsible.ts +75 -0
  173. package/src/lib/composer.ts +89 -0
  174. package/src/lib/data-events.ts +17 -0
  175. package/src/lib/headlines.ts +30 -0
  176. package/src/lib/highlight.ts +186 -0
  177. package/src/lib/layout.ts +20 -0
  178. package/src/lib/lock-screen.ts +54 -0
  179. package/src/lib/model-picker.ts +133 -0
  180. package/src/lib/onboarding.ts +38 -0
  181. package/src/lib/passkeys.ts +113 -0
  182. package/src/lib/pending-prompt.ts +53 -0
  183. package/src/lib/permission-info.ts +101 -0
  184. package/src/lib/project-extensions.ts +74 -0
  185. package/src/lib/project-overview.ts +117 -0
  186. package/src/lib/prompts.ts +87 -0
  187. package/src/lib/references.ts +106 -0
  188. package/src/lib/routes.ts +115 -0
  189. package/src/lib/session-handoff.ts +25 -0
  190. package/src/lib/session-status.ts +45 -0
  191. package/src/lib/session-summary.ts +68 -0
  192. package/src/lib/session-title.ts +63 -0
  193. package/src/lib/sidebar-groups.ts +78 -0
  194. package/src/lib/skill-installs.ts +360 -0
  195. package/src/lib/skills-catalog.ts +99 -0
  196. package/src/lib/time.ts +22 -0
  197. package/src/lib/tone.ts +80 -0
  198. package/src/lib/turns.ts +320 -0
  199. package/src/lib/utils.ts +6 -0
  200. package/src/logo.svg +31 -0
  201. package/src/pages/projects/ProjectDetailPage.tsx +130 -0
  202. package/src/pages/projects/ProjectsPage.tsx +97 -0
  203. package/src/pages/sessions/SessionPage.tsx +122 -0
  204. package/src/pages/settings/SettingsPage.tsx +299 -0
  205. package/src/pages/settings/__tests__/SettingsPage.test.ts +9 -0
  206. package/src/pages/skills/SkillsPage.tsx +701 -0
  207. package/src/paths.ts +121 -0
  208. package/src/runtime.ts +24 -0
  209. package/src/server/__tests__/app.test.ts +1031 -0
  210. package/src/server/__tests__/auth.test.ts +370 -0
  211. package/src/server/__tests__/config.test.ts +101 -0
  212. package/src/server/__tests__/db.test.ts +485 -0
  213. package/src/server/__tests__/host.test.ts +14 -0
  214. package/src/server/__tests__/tunnel-config.test.ts +38 -0
  215. package/src/server/app.ts +415 -0
  216. package/src/server/db/auth.ts +311 -0
  217. package/src/server/db/messages.ts +50 -0
  218. package/src/server/db/projects.ts +106 -0
  219. package/src/server/db/prompts.ts +78 -0
  220. package/src/server/db/rows.ts +150 -0
  221. package/src/server/db/schema.ts +221 -0
  222. package/src/server/db/sessions.ts +239 -0
  223. package/src/server/db/tunnel.ts +124 -0
  224. package/src/server/host.ts +12 -0
  225. package/src/server/result.ts +15 -0
  226. package/src/server/routes/auth-guard.ts +156 -0
  227. package/src/server/routes/auth.routes.ts +287 -0
  228. package/src/server/routes/helpers.ts +165 -0
  229. package/src/server/routes/meta.routes.ts +24 -0
  230. package/src/server/routes/passkeys.routes.ts +161 -0
  231. package/src/server/routes/projects.routes.ts +157 -0
  232. package/src/server/routes/sessions.routes.ts +197 -0
  233. package/src/server/routes/skills.routes.ts +117 -0
  234. package/src/server/routes/tunnel.routes.ts +132 -0
  235. package/src/server/routes/types.ts +35 -0
  236. package/src/server/routes/uploads.routes.ts +54 -0
  237. package/src/server/routes/workspace.routes.ts +127 -0
  238. package/src/server/services/__tests__/app-info.test.ts +18 -0
  239. package/src/server/services/__tests__/attachments.test.ts +159 -0
  240. package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
  241. package/src/server/services/__tests__/auth.test.ts +341 -0
  242. package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
  243. package/src/server/services/__tests__/git-info.test.ts +74 -0
  244. package/src/server/services/__tests__/opencode.test.ts +320 -0
  245. package/src/server/services/__tests__/project-manager.test.ts +190 -0
  246. package/src/server/services/__tests__/sandbox.test.ts +141 -0
  247. package/src/server/services/__tests__/session-manager.test.ts +3469 -0
  248. package/src/server/services/__tests__/skill-install-jobs.test.ts +226 -0
  249. package/src/server/services/__tests__/skills-registry.test.ts +359 -0
  250. package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
  251. package/src/server/services/__tests__/tunnel.test.ts +367 -0
  252. package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
  253. package/src/server/services/__tests__/workspace.test.ts +249 -0
  254. package/src/server/services/app-info.ts +29 -0
  255. package/src/server/services/attachments.ts +193 -0
  256. package/src/server/services/auth-passkeys.ts +459 -0
  257. package/src/server/services/auth.ts +633 -0
  258. package/src/server/services/frpc-installer.ts +183 -0
  259. package/src/server/services/git-info.ts +87 -0
  260. package/src/server/services/opencode-client.ts +714 -0
  261. package/src/server/services/opencode-server.ts +319 -0
  262. package/src/server/services/opencode-sse.ts +82 -0
  263. package/src/server/services/project-manager.ts +147 -0
  264. package/src/server/services/sandbox.ts +83 -0
  265. package/src/server/services/session-events.ts +256 -0
  266. package/src/server/services/session-manager.ts +1374 -0
  267. package/src/server/services/skill-install-jobs.ts +262 -0
  268. package/src/server/services/skills-registry.ts +468 -0
  269. package/src/server/services/tunnel-api.ts +207 -0
  270. package/src/server/services/tunnel-config.ts +45 -0
  271. package/src/server/services/tunnel-lhr.ts +387 -0
  272. package/src/server/services/tunnel.ts +633 -0
  273. package/src/server/services/turn-stream.ts +339 -0
  274. package/src/server/services/websocket-gateway.ts +374 -0
  275. package/src/server/services/workspace.ts +223 -0
  276. package/src/server/services/ws-transport.ts +121 -0
  277. package/src/types/context.ts +69 -0
  278. package/src/types/index.ts +13 -0
  279. package/src/types/message.ts +58 -0
  280. package/src/types/project.ts +16 -0
  281. package/src/types/prompt.ts +118 -0
  282. package/src/types/session.ts +52 -0
  283. package/src/types/turns.ts +47 -0
  284. package/src/ws-protocol.ts +93 -0
  285. package/styles/globals.css +311 -0
  286. package/tsconfig.json +37 -0
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Tampilan Session — versi headless (opencode serve).
3
+ *
4
+ * Berbeda dari versi PTY/TUI (yang me-render chunk byte terminal TUI):
5
+ * - Pesan datang sebagai `SessionMessage` terstruktur (`role` + `parts`):
6
+ * text, reasoning (collapsible per part, urut sesuai alur), tool/step.
7
+ * - Interactive_Prompt (permission/question) dirender via `PromptCard.tsx`.
8
+ * - Saat model merespon (`turn_active` true / pesan streaming) tombol kirim
9
+ * berubah jadi tombol Stop (kirim `{ type: "interrupt" }`) — menghentikan
10
+ * balasan saja ala opencode, Session tetap berjalan. `{ type: "stop" }`
11
+ * (menonaktifkan Session) tetap ada lewat daftar Session / HTTP.
12
+ *
13
+ * File ini sekarang HANYA menyusun area layar dari bagian-bagian yang
14
+ * sudah dipisah:
15
+ * - State & alur WS: `hooks/useSessionChat.ts`.
16
+ * - Logika transformasi pesan murni: `lib/turns.ts` (type di `types/turns.ts`).
17
+ * - Area UI: `SessionHeader`, `SessionTimeline`, `PromptPanel`,
18
+ * `SessionComposer` di folder yang sama.
19
+ * - Drag & drop file: `FileDropOverlay` (portal, menutupi seluruh viewport)
20
+ * meneruskan file ke composer (`SessionComposerHandle.addFiles`).
21
+ * - Kartu konteks kanan (`ContextPanel`): Environment / MCP / Task list /
22
+ * Reference, MENGAPUNG di margin kanan — percakapan & input tidak bergeser
23
+ * saat kartu dibuka/ditutup. Di mobile tampil sebagai bottom sheet.
24
+ */
25
+ import { useMemo, useRef, useState } from "react";
26
+ import { ConfirmSessionDeleteDialog } from "@/components/sessions/ConfirmSessionDeleteDialog";
27
+ import {
28
+ ContextPanel,
29
+ ContextPanelTrigger,
30
+ ContextSheet,
31
+ } from "@/components/sessions/ContextPanel";
32
+ import { FileDropOverlay } from "@/components/sessions/FileDropOverlay";
33
+ import { SessionComposer, type SessionComposerHandle } from "@/components/sessions/SessionComposer";
34
+ import { SessionHeader } from "@/components/sessions/SessionHeader";
35
+ import { SessionTimeline } from "@/components/sessions/SessionTimeline";
36
+ import {
37
+ SessionComposerSkeleton,
38
+ SessionTimelineSkeleton,
39
+ } from "@/components/sessions/SessionTimelineSkeleton";
40
+ import { useIsMobile } from "@/hooks/useMediaQuery";
41
+ import { useSessionChat } from "@/hooks/useSessionChat";
42
+ import { useSessionContext } from "@/hooks/useSessionContext";
43
+ import { CONVERSATION_MAX_W } from "@/lib/layout";
44
+ import { collectReferences } from "@/lib/references";
45
+ import { cn } from "@/lib/utils";
46
+ import type { Session } from "@/types";
47
+ import { PromptPanel } from "./PromptPanel";
48
+
49
+ export interface SessionViewProps {
50
+ session: Session;
51
+ onBack: () => void;
52
+ /**
53
+ * Dipanggil setelah Session dihapus dari menu aksi header. Bila tidak
54
+ * diberikan, `onBack` dipakai — halaman ini tidak punya data lagi untuk
55
+ * ditampilkan setelah Session hilang.
56
+ */
57
+ onDeleted?: () => void;
58
+ }
59
+
60
+ export function SessionView({ session, onBack, onDeleted }: SessionViewProps) {
61
+ const chat = useSessionChat({ session, onBack, onDeleted });
62
+ /**
63
+ * Riwayat belum tiba dari WebSocket. `messages` kosong di fase ini BUKAN
64
+ * percakapan kosong — tampilkan kerangka, jangan empty-state. Pengecualian:
65
+ * pesan optimistis (prompt pertama dari homepage) sudah ada sejak frame
66
+ * pertama, jadi langsung tampil sebagai percakapan.
67
+ */
68
+ const loadingHistory = !chat.historyLoaded && chat.messages.length === 0;
69
+ const empty = !loadingHistory && chat.messages.length === 0;
70
+ const composerRef = useRef<SessionComposerHandle>(null);
71
+ /** Panel konteks kanan; default tertutup agar area chat tetap lega. */
72
+ const [contextOpen, setContextOpen] = useState(false);
73
+ /** Layar sempit: konteks tampil sebagai bottom sheet, bukan kartu. */
74
+ const isMobile = useIsMobile();
75
+ const sessionCtx = useSessionContext(session.id, contextOpen, chat.generating);
76
+ /**
77
+ * Reference diturunkan dari parts pesan yang sudah ada di timeline
78
+ * (tanpa request tambahan). `session.cwd` dipakai untuk mengubah path
79
+ * absolut dari tool call menjadi path relatif project.
80
+ */
81
+ const references = useMemo(
82
+ () => (contextOpen ? collectReferences(chat.messages, session.cwd) : []),
83
+ [contextOpen, chat.messages, session.cwd],
84
+ );
85
+ // Composer tidak dirender saat pertanyaan menunggu jawaban (panel docked)
86
+ // maupun saat riwayat masih dimuat (belum tahu tata letak akhirnya).
87
+ const composerShown = !loadingHistory && (empty || !chat.hasPendingQuestion);
88
+ const acceptingFiles = composerShown && chat.canInput;
89
+ const blockedReason = loadingHistory
90
+ ? "Wait for the conversation to load, then drop your files."
91
+ : !composerShown
92
+ ? "Answer the question first, then add your files."
93
+ : chat.busy
94
+ ? "Wait for the response to finish, then drop your files."
95
+ : "Start the session to add files.";
96
+
97
+ return (
98
+ // `data-vt-name`: tujuan transisi "to-session" — composer homepage meluas
99
+ // menjadi seluruh area ini (lihat styles/globals.css).
100
+ <div data-vt-name="session-surface" className="flex h-full min-h-0 flex-col">
101
+ <SessionHeader
102
+ session={session}
103
+ status={chat.status}
104
+ wsStatus={chat.wsStatus}
105
+ onStop={() => void chat.stop()}
106
+ onStart={() => void chat.start()}
107
+ onRequestDelete={chat.beginDelete}
108
+ stopping={chat.stopping}
109
+ starting={chat.starting}
110
+ />
111
+
112
+ {/* Percakapan + kartu konteks berdampingan. `relative` jadi acuan tombol
113
+ hamburger (kanan bawah header) dan kartu saat mengapung di layar
114
+ sempit. */}
115
+ <div className="relative flex min-h-0 flex-1">
116
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col">
117
+ {loadingHistory ? (
118
+ // Kerangka sama persis dengan fase `SessionPage` -> tanpa kedipan.
119
+ <>
120
+ <SessionTimelineSkeleton />
121
+ <SessionComposerSkeleton />
122
+ </>
123
+ ) : empty ? (
124
+ /* Empty-state ala Gemini: sapaan + composer dipusatkan vertikal.
125
+ Timeline & prompt panel belum dirender (tidak ada isinya); spacer
126
+ flex-1 di atas & bawah menjaga blok ini tepat di tengah kolom di
127
+ bawah header. Begitu pesan pertama masuk (echo WS), layout pindah
128
+ ke varian percakapan di bawah — composer turun ke dasar. */
129
+ <div className="flex min-h-0 flex-1 flex-col px-3 sm:px-4">
130
+ <div className="min-h-0 flex-[2]" />
131
+ <div className={cn("mx-auto w-full", CONVERSATION_MAX_W)}>
132
+ <h1 className="mb-6 text-center text-2xl font-medium tracking-tight sm:text-3xl">
133
+ No conversation yet
134
+ </h1>
135
+ <SessionComposer
136
+ ref={composerRef}
137
+ session={session}
138
+ inputAllowed={chat.canInput}
139
+ busy={chat.busy}
140
+ generating={chat.generating}
141
+ wsStatus={chat.wsStatus}
142
+ send={chat.send}
143
+ reportError={chat.reportError}
144
+ onInterrupt={chat.interrupt}
145
+ />
146
+ </div>
147
+ <div className="min-h-0 flex-[3]" />
148
+ </div>
149
+ ) : (
150
+ <>
151
+ <SessionTimeline
152
+ error={chat.error}
153
+ status={chat.status}
154
+ messages={chat.messages}
155
+ collapsible={chat.collapsible}
156
+ onToggle={chat.toggleBlock}
157
+ generating={chat.generating}
158
+ />
159
+
160
+ {/* Question pending: panel DOCKED menggantikan composer — jawaban
161
+ pertanyaan adalah satu-satunya input saat ini (ala TUI opencode).
162
+ Hanya area jawaban kartu yang scroll; footer aksi tetap terlihat.
163
+ Permission (bukan question): panel tetap mengambang DI ATAS
164
+ composer seperti biasa. */}
165
+ <PromptPanel
166
+ groups={chat.promptGroups}
167
+ errorSignal={chat.promptError}
168
+ onResolve={chat.resolvePrompt}
169
+ onConsumeError={chat.consumePromptError}
170
+ docked={chat.hasPendingQuestion}
171
+ />
172
+
173
+ {!chat.hasPendingQuestion && (
174
+ <SessionComposer
175
+ ref={composerRef}
176
+ session={session}
177
+ inputAllowed={chat.canInput}
178
+ busy={chat.busy}
179
+ generating={chat.generating}
180
+ wsStatus={chat.wsStatus}
181
+ send={chat.send}
182
+ reportError={chat.reportError}
183
+ onInterrupt={chat.interrupt}
184
+ />
185
+ )}
186
+ </>
187
+ )}
188
+ </div>
189
+
190
+ {/* Tertutup: hanya tombol hamburger. Terbuka: tombol hilang & kartu
191
+ mengapung di kanan. `absolute` = tidak mengambil ruang layout, jadi
192
+ percakapan & input tetap di posisinya. Wadah `pointer-events-none`
193
+ (setinggi area) agar klik di sekitar kartu tetap sampai ke chat. */}
194
+ {isMobile ? (
195
+ // Mobile: bottom sheet. Tombol hamburger tetap terlihat — sheet
196
+ // menutupi layar & punya penutupnya sendiri (tap luar / geser / Esc).
197
+ <>
198
+ <ContextPanelTrigger onOpen={() => setContextOpen(true)} />
199
+ <ContextSheet
200
+ open={contextOpen}
201
+ onOpenChange={setContextOpen}
202
+ context={sessionCtx.context}
203
+ references={references}
204
+ loading={sessionCtx.loading}
205
+ />
206
+ </>
207
+ ) : contextOpen ? (
208
+ <div className="pointer-events-none absolute end-3 top-2 bottom-3 z-20 flex w-[min(15rem,calc(100%-1.5rem))] flex-col sm:end-4">
209
+ <ContextPanel
210
+ context={sessionCtx.context}
211
+ references={references}
212
+ loading={sessionCtx.loading}
213
+ onClose={() => setContextOpen(false)}
214
+ />
215
+ </div>
216
+ ) : (
217
+ <ContextPanelTrigger onOpen={() => setContextOpen(true)} />
218
+ )}
219
+ </div>
220
+
221
+ <FileDropOverlay
222
+ accepting={acceptingFiles}
223
+ blockedReason={blockedReason}
224
+ onDropFiles={(files, from) => composerRef.current?.addFiles(files, from)}
225
+ />
226
+
227
+ {/* Konfirmasi hapus Session dari menu aksi header */}
228
+ <ConfirmSessionDeleteDialog
229
+ open={chat.deleteOpen}
230
+ onOpenChange={(open) => (open ? chat.beginDelete() : chat.cancelDelete())}
231
+ deleting={chat.deleting}
232
+ onConfirm={() => void chat.confirmDelete()}
233
+ />
234
+ </div>
235
+ );
236
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Satu blok "Thought process" collapsible di dalam turn assistant.
3
+ *
4
+ * Berisi kluster steps berurutan (reasoning/tool) dengan garis penghubung
5
+ * antar langkah + tanda "Done" setelah blok selesai di-stream. Status toggle
6
+ * dikelola pemilik daftar (key `groupId:blockKey`) agar blok baru saat
7
+ * streaming otomatis masuk keadaan collapsed tanpa menyentuh toggle user.
8
+ */
9
+ import {
10
+ ChevronRightIcon,
11
+ CircleCheckBigIcon,
12
+ ClockFadingIcon,
13
+ FileIcon,
14
+ FolderIcon,
15
+ GlobeIcon,
16
+ type LucideIcon,
17
+ PenLineIcon,
18
+ SearchIcon,
19
+ TerminalIcon,
20
+ WrenchIcon,
21
+ } from "lucide-react";
22
+ import type { ReactNode } from "react";
23
+ import { Button } from "@/components/ui/button";
24
+ import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible";
25
+ import { useSmoothText } from "@/hooks/useSmoothText";
26
+ import { type CollapsibleState, isCollapsibleExpanded } from "@/lib/collapsible";
27
+ import { partText, toolLabel } from "@/lib/turns";
28
+ import { cn } from "@/lib/utils";
29
+ import type { MessagePart, TurnSegment } from "@/types";
30
+
31
+ /** Step dalam blok: reasoning atau tool call. */
32
+ export type ThoughtStep = Extract<TurnSegment, { kind: "reasoning" | "tool" }>;
33
+
34
+ export interface ThoughtProcessBlockProps {
35
+ /** Id grup turn (untuk key collapsible `groupId:blockKey`). */
36
+ groupId: string;
37
+ /** Key blok (dari key segmen pertama). */
38
+ blockKey: string;
39
+ steps: ThoughtStep[];
40
+ collapsible: CollapsibleState;
41
+ onToggle: (key: string) => void;
42
+ /** Turn masih streaming — blok terakhir menampilkan indikator aktif. */
43
+ streaming: boolean;
44
+ /** Apakah ini blok terakhir dalam turn (menentukan indikator streaming). */
45
+ isLastBlock: boolean;
46
+ }
47
+
48
+ /**
49
+ * Ikon badge tool mengikuti nama tool (fallback: kunci inggris).
50
+ */
51
+ const TOOL_ICONS: Record<string, LucideIcon> = {
52
+ read: FileIcon,
53
+ list: FolderIcon,
54
+ glob: FolderIcon,
55
+ edit: PenLineIcon,
56
+ write: PenLineIcon,
57
+ patch: PenLineIcon,
58
+ bash: TerminalIcon,
59
+ shell: TerminalIcon,
60
+ grep: SearchIcon,
61
+ webfetch: GlobeIcon,
62
+ };
63
+
64
+ function toolIcon(part: MessagePart): LucideIcon {
65
+ const name = (typeof part.tool === "string" ? part.tool : part.type).toLowerCase();
66
+ return (name !== "" && TOOL_ICONS[name]) || WrenchIcon;
67
+ }
68
+
69
+ /** Ikon kecil (14px) untuk langkah timeline & indikator status. */
70
+ function stepIcon(Icon: LucideIcon, className?: string) {
71
+ return <Icon className={cn("size-3.5 shrink-0", className)} data-icon="inline-start" />;
72
+ }
73
+
74
+ /**
75
+ * Baris ringkas isi reasoning di timeline. Saat masih di-stream (teks belum
76
+ * lengkap) tampil redup; setelah selesai tampil apa adanya — keduanya bisa
77
+ * panjang, jadi tinggi dibatasi + scroll internal.
78
+ */
79
+ function ReasoningStep({ text, live = false }: { text: string; live?: boolean }) {
80
+ // Reasoning yang masih di-stream ikut dihaluskan (sama seperti jawaban).
81
+ const shown = useSmoothText(text, live);
82
+ const dim = shown.trim() === "";
83
+ return (
84
+ <div
85
+ className={cn(
86
+ "max-h-40 overflow-y-auto whitespace-pre-wrap text-[13px] leading-relaxed",
87
+ dim && "text-muted-foreground/50",
88
+ )}
89
+ >
90
+ {dim ? "organizing thoughts…" : shown}
91
+ </div>
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Satu langkah timeline di dalam blok "Thought process": ikon status di
97
+ * kiri (dengan garis penghubung antar langkah) + isi di kanan.
98
+ *
99
+ * Garis digambar ABSOLUT (bukan flex-1 di kolom stretch): `align-self:
100
+ * stretch` hanya membentang sampai content box sehingga di langkah
101
+ * satu-baris sisa ruang garisnya ~0px (tak terlihat). Absolut terhadap
102
+ * PADDING box -> `bottom-0` menyentuh dasar `pb-5`, artinya garis selalu
103
+ * tersambung ke langkah berikutnya berapa pun tinggi kontennya.
104
+ */
105
+ function renderTimelineStep(
106
+ icon: ReactNode,
107
+ content: ReactNode,
108
+ opts?: { last?: boolean; streaming?: boolean },
109
+ ) {
110
+ return (
111
+ <div className={cn("relative flex gap-2.5", !opts?.last && "pb-5")}>
112
+ {/* Garis penghubung: mulai tepat di bawah ikon (mt-0.5 + size-5 =
113
+ 22px) sampai dasar row; x=10px = pusat kolom ikon (size-5). */}
114
+ {!opts?.last && (
115
+ <span className="absolute top-6 bottom-0 left-2.5 -ml-px w-px bg-border" aria-hidden />
116
+ )}
117
+ <span
118
+ className={cn(
119
+ "mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full",
120
+ opts?.streaming && "animate-pulse text-foreground/70",
121
+ )}
122
+ >
123
+ {icon}
124
+ </span>
125
+ <div className="min-w-0 flex-1 text-sm text-muted-foreground">{content}</div>
126
+ </div>
127
+ );
128
+ }
129
+
130
+ export function ThoughtProcessBlock({
131
+ groupId,
132
+ blockKey,
133
+ steps,
134
+ collapsible,
135
+ onToggle,
136
+ streaming,
137
+ isLastBlock,
138
+ }: ThoughtProcessBlockProps) {
139
+ if (steps.length === 0) return null;
140
+ const collapsibleKey = `${groupId}:${blockKey}`;
141
+ const expanded = isCollapsibleExpanded(collapsible, collapsibleKey);
142
+ // Blok terakhir yang masih streaming: "Done" belum tampil.
143
+ const blockStreaming = streaming && isLastBlock;
144
+
145
+ return (
146
+ <Collapsible open={expanded}>
147
+ <Button
148
+ type="button"
149
+ variant="ghost"
150
+ size="sm"
151
+ className="h-7 self-start gap-1 px-2 text-xs text-muted-foreground"
152
+ onClick={() => onToggle(collapsibleKey)}
153
+ aria-expanded={expanded}
154
+ >
155
+ Thought process
156
+ {/* Ikon sengaja di KANAN label */}
157
+ <ChevronRightIcon
158
+ data-icon="inline-end"
159
+ className={cn("transition-transform", expanded && "rotate-90")}
160
+ />
161
+ </Button>
162
+ <CollapsibleContent>
163
+ <div className="flex flex-col px-3 pt-1">
164
+ {steps.map((seg, i) =>
165
+ seg.kind === "reasoning" ? (
166
+ <div key={seg.key}>
167
+ {renderTimelineStep(
168
+ stepIcon(ClockFadingIcon, "text-muted-foreground"),
169
+ <ReasoningStep
170
+ text={partText(seg.part) ?? ""}
171
+ live={blockStreaming && i === steps.length - 1}
172
+ />,
173
+ {
174
+ last: blockStreaming && i === steps.length - 1,
175
+ streaming: blockStreaming && i === steps.length - 1,
176
+ },
177
+ )}
178
+ </div>
179
+ ) : (
180
+ <div key={seg.key}>
181
+ {renderTimelineStep(
182
+ stepIcon(toolIcon(seg.part), "text-muted-foreground"),
183
+ <span className="font-mono text-[13px]">{toolLabel(seg.part)}</span>,
184
+ { last: blockStreaming && i === steps.length - 1, streaming: false },
185
+ )}
186
+ </div>
187
+ ),
188
+ )}
189
+ {!blockStreaming &&
190
+ renderTimelineStep(
191
+ stepIcon(CircleCheckBigIcon, "text-muted-foreground"),
192
+ <span>Done</span>,
193
+ { last: true },
194
+ )}
195
+ </div>
196
+ </CollapsibleContent>
197
+ </Collapsible>
198
+ );
199
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Render satu pesan user: teks + lampiran gambar (thumbnail) + @file.
3
+ *
4
+ * Part `file` gambar (punya `attachmentId` + mime image) dimuat bytes-nya
5
+ * lewat route HTTP `/api/uploads/...` (`attachmentUrl`); file upload lain
6
+ * tampil sebagai kartu yang bisa diunduh; part `file` tanpa `attachmentId`
7
+ * (echo @file) tampil sebagai chip nama file.
8
+ */
9
+ import { FileIcon } from "lucide-react";
10
+ import { useState } from "react";
11
+ import { FileTile } from "@/components/sessions/AttachmentTile";
12
+ import { ImageLightbox } from "@/components/sessions/ImageLightbox";
13
+ import { CopyMessageButton } from "@/components/sessions/MessageActions";
14
+ import { Bubble, BubbleContent } from "@/components/ui/bubble";
15
+ import { Message, MessageContent, MessageFooter } from "@/components/ui/message";
16
+ import { attachmentUrl } from "@/lib/api";
17
+ import { isPreviewableImage } from "@/lib/attachments";
18
+ import { splitComposerMentions } from "@/lib/composer";
19
+ import { formatTime } from "@/lib/time";
20
+ import { textOf } from "@/lib/turns";
21
+ import { cn } from "@/lib/utils";
22
+ import type { MessagePart, SessionMessage } from "@/types";
23
+
24
+ export interface UserMessageProps {
25
+ message: SessionMessage;
26
+ }
27
+
28
+ /** Part `file` yang merupakan gambar lampiran (punya attachmentId + mime image). */
29
+ function isImageAttachment(
30
+ p: MessagePart,
31
+ ): p is MessagePart & { mime: string; attachmentId: string } {
32
+ return (
33
+ p.type === "file" &&
34
+ typeof p.mime === "string" &&
35
+ isPreviewableImage(p.mime) &&
36
+ typeof p.attachmentId === "string"
37
+ );
38
+ }
39
+
40
+ /** File upload non-gambar (dokumen, kode, arsip, …). */
41
+ function isFileAttachment(
42
+ p: MessagePart,
43
+ ): p is MessagePart & { attachmentId: string; filename: string } {
44
+ return (
45
+ p.type === "file" &&
46
+ typeof p.attachmentId === "string" &&
47
+ typeof p.filename === "string" &&
48
+ !isImageAttachment(p)
49
+ );
50
+ }
51
+
52
+ export function UserMessage({ message: m }: UserMessageProps) {
53
+ const [preview, setPreview] = useState<number | null>(null);
54
+ const images = m.parts.filter(isImageAttachment);
55
+ const text = textOf(m.parts);
56
+ const lightboxImages = images.map((p) => ({
57
+ src: attachmentUrl(m.sessionId, p.attachmentId),
58
+ alt: p.filename ?? "Attached image",
59
+ }));
60
+ const uploadedFiles = m.parts.filter(isFileAttachment);
61
+ /** Echo @file: part `file` tanpa `attachmentId` (path relatif project). */
62
+ const isMention = (p: MessagePart) =>
63
+ p.type === "file" && typeof p.attachmentId !== "string" && typeof p.filename === "string";
64
+ const mentionedPaths = new Set(m.parts.filter(isMention).map((p) => p.filename as string));
65
+ const attachedFiles = m.parts
66
+ .filter(isMention)
67
+ .map((p) => ({ part: p, filename: p.filename as string }));
68
+ return (
69
+ <Message align="end">
70
+ <MessageContent>
71
+ {/* Gambar di atas bubble teks (rata kanan), ala aplikasi chat:
72
+ 1 gambar = satu kartu besar; 2+ = grid 2 kolom kotak. Klik =
73
+ pratinjau layar penuh. */}
74
+ {images.length > 0 && (
75
+ <ul
76
+ aria-label={`${images.length} attached ${images.length === 1 ? "image" : "images"}`}
77
+ className={cn(
78
+ "grid gap-1.5 self-end",
79
+ images.length === 1 ? "w-full max-w-72 grid-cols-1" : "w-full max-w-80 grid-cols-2",
80
+ )}
81
+ >
82
+ {lightboxImages.map((img, i) => (
83
+ <li key={img.src} className={cn(images.length === 3 && i === 0 && "col-span-2")}>
84
+ <button
85
+ type="button"
86
+ onClick={() => setPreview(i)}
87
+ aria-label={`Preview ${img.alt}`}
88
+ className="group/img block w-full overflow-hidden rounded-xl border bg-muted outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
89
+ >
90
+ <img
91
+ src={img.src}
92
+ alt={img.alt}
93
+ loading="lazy"
94
+ draggable={false}
95
+ className={cn(
96
+ "w-full object-cover transition-transform duration-200 group-hover/img:scale-[1.02]",
97
+ images.length === 1 ? "max-h-80" : "aspect-square",
98
+ images.length === 3 && i === 0 && "aspect-[2/1]",
99
+ )}
100
+ />
101
+ </button>
102
+ </li>
103
+ ))}
104
+ </ul>
105
+ )}
106
+
107
+ {/* File non-gambar: kartu unduhan di atas bubble (rata kanan). */}
108
+ {uploadedFiles.length > 0 && (
109
+ <ul
110
+ aria-label={`${uploadedFiles.length} attached ${uploadedFiles.length === 1 ? "file" : "files"}`}
111
+ className="flex max-w-full flex-wrap justify-end gap-1.5 self-end"
112
+ >
113
+ {uploadedFiles.map((p) => (
114
+ <li key={p.attachmentId} className="min-w-0">
115
+ <a
116
+ href={attachmentUrl(m.sessionId, p.attachmentId)}
117
+ download={p.filename}
118
+ title={`Download ${p.filename}`}
119
+ className="block rounded-lg outline-none transition-opacity hover:opacity-90 focus-visible:ring-[3px] focus-visible:ring-ring/50"
120
+ >
121
+ <FileTile
122
+ filename={p.filename}
123
+ mime={p.mime ?? ""}
124
+ className="h-14 w-56 max-w-full"
125
+ />
126
+ </a>
127
+ </li>
128
+ ))}
129
+ </ul>
130
+ )}
131
+
132
+ {(text !== "" || attachedFiles.length > 0) && (
133
+ <Bubble>
134
+ {text !== "" && (
135
+ <BubbleContent className="whitespace-pre-wrap">
136
+ {/* Mention @file disorot sama seperti di composer. */}
137
+ {splitComposerMentions(text, mentionedPaths).map((seg, i) =>
138
+ seg.kind === "mention" ? (
139
+ <mark
140
+ // biome-ignore lint/suspicious/noArrayIndexKey: urutan potongan = urutan teks
141
+ key={i}
142
+ className="rounded-[3px] bg-current/10 text-inherit"
143
+ >
144
+ {seg.text}
145
+ </mark>
146
+ ) : (
147
+ // biome-ignore lint/suspicious/noArrayIndexKey: urutan potongan = urutan teks
148
+ <span key={i}>{seg.text}</span>
149
+ ),
150
+ )}
151
+ </BubbleContent>
152
+ )}
153
+ {attachedFiles.length > 0 && (
154
+ <div className="mt-1.5 flex flex-wrap gap-1">
155
+ {attachedFiles.map(({ filename }) => (
156
+ <span
157
+ key={filename}
158
+ className="inline-flex max-w-full items-center gap-1 rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
159
+ >
160
+ <FileIcon className="size-3 shrink-0" />
161
+ <span className="truncate">{filename}</span>
162
+ </span>
163
+ ))}
164
+ </div>
165
+ )}
166
+ </Bubble>
167
+ )}
168
+ {/* Copy di kiri waktu (pesan user rata kanan). */}
169
+ <MessageFooter className="gap-1">
170
+ <CopyMessageButton text={text} />
171
+ <span className="text-muted-foreground/80">{formatTime(m.createdAt)}</span>
172
+ </MessageFooter>
173
+ <ImageLightbox images={lightboxImages} index={preview} onIndexChange={setPreview} />
174
+ </MessageContent>
175
+ </Message>
176
+ );
177
+ }