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,237 @@
1
+ /**
2
+ * Satu baris Session di daftar (`SessionList`) — baris boxed list ala
3
+ * AdwActionRow (libadwaita), sama dengan baris panel samping Project: logo
4
+ * agent 18px sebagai ikon, judul + subjudul (status berwarna · model ·
5
+ * waktu), tombol menu bundar sebagai suffix, lalu chevron navigasi.
6
+ * Badan baris adalah tombol buka (target sentuh lebar); aksi sekunder ada di
7
+ * menu "..." — di perangkat dengan pointer halus (mouse) menu baru muncul
8
+ * saat hover/fokus agar daftar tetap bersih, di layar sentuh selalu tampil.
9
+ * Pemanggil membungkusnya dengan `<li>` di dalam `PrefsGroup`.
10
+ */
11
+ import {
12
+ CheckIcon,
13
+ ChevronRightIcon,
14
+ MoreHorizontalIcon,
15
+ PlayIcon,
16
+ SquareIcon,
17
+ Trash2Icon,
18
+ } from "lucide-react";
19
+ import { AgentLogo } from "@/components/sessions/AgentLogo";
20
+ import { Button } from "@/components/ui/button";
21
+ import {
22
+ DropdownMenu,
23
+ DropdownMenuContent,
24
+ DropdownMenuItem,
25
+ DropdownMenuSeparator,
26
+ DropdownMenuTrigger,
27
+ } from "@/components/ui/dropdown-menu";
28
+ import { Skeleton } from "@/components/ui/skeleton";
29
+ import { Spinner } from "@/components/ui/spinner";
30
+ import { AGENT_TYPE_LABELS } from "@/lib/agent-types";
31
+ import { formatRelativeTime } from "@/lib/project-overview";
32
+ import { SESSION_STATUS_DOT, SESSION_STATUS_LABEL } from "@/lib/session-status";
33
+ import { describeSessionModel } from "@/lib/session-summary";
34
+ import { displaySessionTitle } from "@/lib/session-title";
35
+ import { cn } from "@/lib/utils";
36
+ import type { Session } from "@/types";
37
+
38
+ export interface SessionRowProps {
39
+ session: Session;
40
+ /** Ada aksi berjalan untuk Session ini — kunci baris agar tidak dobel klik. */
41
+ busy: boolean;
42
+ onOpen: () => void;
43
+ onStop: () => void;
44
+ onStart: () => void;
45
+ onDelete: () => void;
46
+ /**
47
+ * Selection mode (hapus massal): kotak centang di depan, klik baris
48
+ * memilih/membatalkan pilihan, menu & chevron disembunyikan.
49
+ */
50
+ selectionMode?: boolean;
51
+ selected?: boolean;
52
+ onToggleSelect?: () => void;
53
+ }
54
+
55
+ /** Kotak centang ala GtkCheckButton (GTK4): kotak membulat, terisi aksen. */
56
+ function CheckBox({ checked }: { checked: boolean }) {
57
+ return (
58
+ <span
59
+ aria-hidden
60
+ className={cn(
61
+ "flex size-[18px] shrink-0 items-center justify-center rounded-[5px] border-2 transition-colors",
62
+ checked
63
+ ? "border-primary bg-primary text-primary-foreground"
64
+ : "border-muted-foreground/45",
65
+ )}
66
+ >
67
+ {checked && <CheckIcon className="size-3" strokeWidth={3.5} />}
68
+ </span>
69
+ );
70
+ }
71
+
72
+ /** Warna teks status di subjudul (selaras status MCP di panel samping). */
73
+ const STATUS_TEXT: Record<Session["status"], string> = {
74
+ running: "text-emerald-700 dark:text-emerald-400",
75
+ crashed: "text-destructive",
76
+ stopped: "text-muted-foreground",
77
+ };
78
+
79
+ /**
80
+ * Status di awal subjudul — titik + label berwarna, seperti status MCP.
81
+ * `stopped` (keadaan biasa) tidak ditampilkan agar daftar tidak ramai.
82
+ */
83
+ function StatusLabel({ session, busy }: { session: Session; busy: boolean }) {
84
+ if (busy) {
85
+ return (
86
+ <span className="inline-flex shrink-0 items-center gap-1.5">
87
+ <Spinner className="size-3" />
88
+ Working…
89
+ </span>
90
+ );
91
+ }
92
+ if (session.status === "stopped") return null;
93
+ const running = session.status === "running";
94
+ return (
95
+ <span className={cn("inline-flex shrink-0 items-center gap-1.5", STATUS_TEXT[session.status])}>
96
+ <span className="relative flex size-1.5" aria-hidden>
97
+ {running && (
98
+ <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60" />
99
+ )}
100
+ <span
101
+ className={cn(
102
+ "relative inline-flex size-1.5 rounded-full",
103
+ SESSION_STATUS_DOT[session.status],
104
+ )}
105
+ />
106
+ </span>
107
+ {SESSION_STATUS_LABEL[session.status]}
108
+ </span>
109
+ );
110
+ }
111
+
112
+ /** Pemisah "·" di subjudul; jarak kiri/kanan datang dari `gap` induk. */
113
+ function Dot() {
114
+ return (
115
+ <span className="opacity-50" aria-hidden>
116
+ ·
117
+ </span>
118
+ );
119
+ }
120
+
121
+ export function SessionRow({
122
+ session,
123
+ busy,
124
+ onOpen,
125
+ onStop,
126
+ onStart,
127
+ onDelete,
128
+ selectionMode = false,
129
+ selected = false,
130
+ onToggleSelect,
131
+ }: SessionRowProps) {
132
+ const running = session.status === "running";
133
+ const title = displaySessionTitle(session.title);
134
+ const agentLabel = AGENT_TYPE_LABELS[session.agentType];
135
+ const hasStatus = busy || session.status !== "stopped";
136
+
137
+ return (
138
+ // Baris AdwActionRow yang bisa diaktifkan — sama dengan baris di panel
139
+ // samping: ikon simbolik 18px, judul 15px + subjudul 13px, suffix, lalu
140
+ // chevron. Tombol buka "direntangkan" (after:inset-0) sehingga seluruh
141
+ // baris, termasuk chevron, bisa diklik; tombol menu berada di atasnya.
142
+ <div
143
+ className={cn(
144
+ "group relative flex min-h-14 items-center gap-3 pr-4 pl-4 transition-colors hover:bg-muted/60 has-focus-visible:bg-muted/60 has-[button:active]:bg-muted",
145
+ selectionMode && selected && "bg-primary/[0.06]",
146
+ )}
147
+ >
148
+ {selectionMode && <CheckBox checked={selected} />}
149
+ <AgentLogo agentType={session.agentType} className="size-[18px] text-muted-foreground" />
150
+ <button
151
+ type="button"
152
+ onClick={selectionMode ? onToggleSelect : onOpen}
153
+ disabled={busy}
154
+ aria-pressed={selectionMode ? selected : undefined}
155
+ title={`${title} — ${agentLabel}`}
156
+ className="flex min-w-0 flex-1 flex-col gap-0.5 py-2.5 text-left outline-none after:absolute after:inset-0 focus-visible:after:ring-[3px] focus-visible:after:ring-ring/40 focus-visible:after:ring-inset disabled:opacity-60"
157
+ aria-label={`${selectionMode ? "Select" : "Open"} session ${title}, ${agentLabel}, ${SESSION_STATUS_LABEL[session.status]}`}
158
+ >
159
+ <span className="truncate text-[15px] leading-5">{title}</span>
160
+ <span className="flex min-w-0 items-center gap-1.5 text-[13px] leading-snug text-muted-foreground">
161
+ <StatusLabel session={session} busy={busy} />
162
+ {/* Layar sempit + ada status: model disembunyikan (terpotong jadi
163
+ tak terbaca); status & waktu lebih penting. */}
164
+ <span className={cn("contents", hasStatus && "max-sm:hidden")}>
165
+ {hasStatus && <Dot />}
166
+ <span className="truncate">{describeSessionModel(session)}</span>
167
+ </span>
168
+ <Dot />
169
+ <span className="shrink-0 tabular-nums">{formatRelativeTime(session.updatedAt)}</span>
170
+ </span>
171
+ </button>
172
+
173
+ {!selectionMode && (
174
+ <>
175
+ <DropdownMenu>
176
+ <DropdownMenuTrigger asChild>
177
+ {/* Suffix: tombol bundar datar. Mouse: muncul saat hover/fokus;
178
+ layar sentuh: selalu tampil. */}
179
+ <Button
180
+ type="button"
181
+ variant="ghost"
182
+ size="icon-sm"
183
+ disabled={busy}
184
+ aria-label={`Session actions ${title}`}
185
+ className="relative z-10 shrink-0 rounded-full text-muted-foreground transition-opacity pointer-fine:opacity-0 pointer-fine:group-hover:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100"
186
+ >
187
+ <MoreHorizontalIcon />
188
+ </Button>
189
+ </DropdownMenuTrigger>
190
+ <DropdownMenuContent align="end">
191
+ {running ? (
192
+ <DropdownMenuItem onSelect={onStop}>
193
+ <SquareIcon />
194
+ Stop
195
+ </DropdownMenuItem>
196
+ ) : (
197
+ <DropdownMenuItem onSelect={onStart}>
198
+ <PlayIcon />
199
+ Start
200
+ </DropdownMenuItem>
201
+ )}
202
+ <DropdownMenuSeparator />
203
+ <DropdownMenuItem variant="destructive" onSelect={onDelete}>
204
+ <Trash2Icon />
205
+ Delete permanently
206
+ </DropdownMenuItem>
207
+ </DropdownMenuContent>
208
+ </DropdownMenu>
209
+ <ChevronRightIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
210
+ </>
211
+ )}
212
+ </div>
213
+ );
214
+ }
215
+
216
+ /** Placeholder daftar Session saat memuat — bentuk boxed list yang sama. */
217
+ export function SessionListSkeleton() {
218
+ return (
219
+ <div className="flex flex-col gap-2.5" aria-hidden>
220
+ <div className="flex flex-col gap-1.5 px-1">
221
+ <Skeleton className="h-4 w-20" />
222
+ <Skeleton className="h-3 w-36" />
223
+ </div>
224
+ <div className="flex flex-col divide-y overflow-hidden rounded-xl border bg-card">
225
+ {[0, 1, 2].map((i) => (
226
+ <div key={i} className="flex min-h-14 items-center gap-3 px-4 py-2.5">
227
+ <Skeleton className="size-[18px] shrink-0 rounded" />
228
+ <div className="flex min-w-0 flex-1 flex-col gap-1.5">
229
+ <Skeleton className="h-4" style={{ width: `${55 - i * 10}%` }} />
230
+ <Skeleton className="h-3 w-40" />
231
+ </div>
232
+ </div>
233
+ ))}
234
+ </div>
235
+ </div>
236
+ );
237
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Timeline percakapan terstruktur: daftar pesan yang dapat di-scroll dengan
3
+ * tombol "kembali ke bawah", maskot global, serta catatan error/empty/status.
4
+ *
5
+ * Dipisah dari `SessionView` agar halaman hanya menyusun area; pemetaan
6
+ * pesan -> grup turn & status maskot dihitung di sini dari `messages` murni
7
+ * (`lib/turns.ts`).
8
+ */
9
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
10
+ import {
11
+ MessageScroller,
12
+ MessageScrollerButton,
13
+ MessageScrollerContent,
14
+ MessageScrollerItem,
15
+ MessageScrollerProvider,
16
+ MessageScrollerViewport,
17
+ useMessageScroller,
18
+ } from "@/components/ui/message-scroller";
19
+ import type { CollapsibleState } from "@/lib/collapsible";
20
+ import { TIMELINE_MAX_W } from "@/lib/layout";
21
+ import { groupTurns, lastAssistantGroup, textOf, turnStatus } from "@/lib/turns";
22
+ import { cn } from "@/lib/utils";
23
+ import type { SessionMessage, SessionStatus } from "@/types";
24
+ import { AssistantTurn } from "./AssistantTurn";
25
+ import { Mascot } from "./Mascot";
26
+ import { MessageRail, type UserMessageMark } from "./MessageRail";
27
+ import { UserMessage } from "./UserMessage";
28
+
29
+ export interface SessionTimelineProps {
30
+ /** Error banner global (bukan error kartu prompt). */
31
+ error: string | null;
32
+ /** Status Session saat ini (catatan "Session stopped…" di bawah). */
33
+ status: SessionStatus;
34
+ messages: SessionMessage[];
35
+ /** Status toggle blok "Thought process" per turn. */
36
+ collapsible: CollapsibleState;
37
+ onToggle: (key: string) => void;
38
+ /** Model sedang merespon (turn aktif + Session running). */
39
+ generating: boolean;
40
+ }
41
+
42
+ export function SessionTimeline({
43
+ error,
44
+ status,
45
+ messages,
46
+ collapsible,
47
+ onToggle,
48
+ generating,
49
+ }: SessionTimelineProps) {
50
+ /**
51
+ * Ambang "menempel ke bawah" (dan kemunculan tombol panah) = setinggi SATU
52
+ * viewport. Tinggi area scroll diukur dinamis (bukan angka tetap) agar
53
+ * perilakunya benar di tiap ukuran layar / saat tinggi area berubah (mis.
54
+ * composer membesar).
55
+ */
56
+ const viewportRef = useRef<HTMLDivElement | null>(null);
57
+ const [viewportHeight, setViewportHeight] = useState(0);
58
+
59
+ useLayoutEffect(() => {
60
+ const viewport = viewportRef.current;
61
+ if (viewport === null) return;
62
+ const update = () => setViewportHeight(viewport.clientHeight);
63
+ update();
64
+ if (typeof ResizeObserver === "undefined") return;
65
+ const observer = new ResizeObserver(update);
66
+ observer.observe(viewport);
67
+ return () => observer.disconnect();
68
+ }, []);
69
+
70
+ const groups = useMemo(() => groupTurns(messages), [messages]);
71
+ const lastTurn = lastAssistantGroup(groups);
72
+
73
+ /**
74
+ * Penanda rail: satu per pesan user, dengan cuplikan teks untuk tooltip.
75
+ * Pesan tanpa teks (mis. lampiran saja) tetap dapat penanda.
76
+ */
77
+ const marks = useMemo<UserMessageMark[]>(
78
+ () =>
79
+ messages
80
+ .filter((m) => m.role === "user")
81
+ .map((m) => {
82
+ const text = textOf(m.parts).trim().replace(/\s+/g, " ");
83
+ return {
84
+ id: m.id,
85
+ preview: text === "" ? "Attachment" : text.slice(0, 120),
86
+ };
87
+ }),
88
+ [messages],
89
+ );
90
+
91
+ /** Elemen tiap pesan user (target observer & tujuan lompat). */
92
+ const markEls = useRef(new Map<string, HTMLElement>());
93
+ const [activeMark, setActiveMark] = useState<string | null>(null);
94
+
95
+ // Penanda aktif = pesan user paling bawah yang bagian atasnya sudah lewat
96
+ // (atau dekat) tepi atas viewport — itulah bagian percakapan yang sedang
97
+ // dibaca. Dihitung dari rect, tidak menyimpan state scroll sendiri.
98
+ const syncActive = useCallback(() => {
99
+ const viewport = viewportRef.current;
100
+ if (viewport === null) return;
101
+ const top = viewport.getBoundingClientRect().top;
102
+ let current: string | null = null;
103
+ for (const mark of marks) {
104
+ const el = markEls.current.get(mark.id);
105
+ if (el && el.getBoundingClientRect().top - top <= 24) current = mark.id;
106
+ }
107
+ setActiveMark(current ?? marks[0]?.id ?? null);
108
+ }, [marks]);
109
+
110
+ useEffect(() => {
111
+ const viewport = viewportRef.current;
112
+ if (viewport === null || marks.length < 2) return;
113
+ syncActive();
114
+ // `passive`: handler hanya membaca rect, tidak pernah preventDefault.
115
+ viewport.addEventListener("scroll", syncActive, { passive: true });
116
+ return () => viewport.removeEventListener("scroll", syncActive);
117
+ }, [marks.length, syncActive]);
118
+
119
+ const registerMarkEl = useCallback((id: string, el: HTMLElement | null) => {
120
+ if (el) markEls.current.set(id, el);
121
+ else markEls.current.delete(id);
122
+ }, []);
123
+
124
+ const jumpTo = useCallback((id: string) => {
125
+ const el = markEls.current.get(id);
126
+ const viewport = viewportRef.current;
127
+ if (!el || !viewport) return;
128
+ // Lompat dengan sedikit ruang di atas agar pesan tidak menempel ke tepi.
129
+ const offset = el.getBoundingClientRect().top - viewport.getBoundingClientRect().top;
130
+ viewport.scrollTo({ top: viewport.scrollTop + offset - 16, behavior: "smooth" });
131
+ }, []);
132
+
133
+ /**
134
+ * Maskot GLOBAL (satu instance, bukan per-turn): status dihitung dari grup
135
+ * assistant yang masih LIVE (sedang streaming) dan ditampilkan tetap di atas
136
+ * composer — tidak ikut scroll bersama riwayat percakapan.
137
+ *
138
+ * Turn lama yang sudah selesai TIDAK ikut menentukan status: begitu user
139
+ * mengirim pesan baru, grup assistant terakhir masih milik jawaban
140
+ * sebelumnya — tanpa filter ini maskot langsung menampilkan "Writing…"
141
+ * (dari jawaban lama) padahal model belum mulai merespons. Belum ada
142
+ * konten baru -> "Starting…" (lihat fallback di bawah).
143
+ */
144
+ const liveTurn = lastTurn?.messages.some((m) => m.streaming === true) ? lastTurn : null;
145
+ const mascotStatus = generating
146
+ ? ((liveTurn ? turnStatus(liveTurn.messages, true) : null) ?? "Starting…")
147
+ : null;
148
+
149
+ return (
150
+ // `autoScroll` bawaan provider DIMATIKAN: mesinnya menempel ke dasar setiap
151
+ // kali tinggi konten berubah — termasuk saat user membuka/menutup blok
152
+ // "Thought process" (state collapsible) sehingga layar terseret ke bawah.
153
+ // Penggantinya `AutoScrollFollow` di bawah: hanya mengikuti saat isi
154
+ // TIMELINE bertambah (pesan baru/streaming/status/error), bukan saat
155
+ // toggle collapsible. `scrollEdgeThreshold` tetap dipakai untuk ambang
156
+ // tombol panah (1 viewport).
157
+ <MessageScrollerProvider autoScroll={false} scrollEdgeThreshold={viewportHeight}>
158
+ {/* Pengganti auto-follow bawaan — lihat komentar komponen di bawah. */}
159
+ <AutoScrollFollow
160
+ messages={messages}
161
+ status={status}
162
+ error={error}
163
+ viewportRef={viewportRef}
164
+ />
165
+ <MessageScroller className="min-h-0 flex-1">
166
+ <MessageScrollerViewport ref={viewportRef}>
167
+ {/* `TIMELINE_MAX_W mx-auto`: di layar lebar baris teks yang membentang
168
+ penuh sulit dibaca; kolom percakapan dibatasi dan dipusatkan
169
+ seperti aplikasi chat lain, sementara scrollbar tetap di tepi. */}
170
+ <MessageScrollerContent
171
+ className={cn("mx-auto w-full gap-6 px-3 py-4 sm:gap-8 sm:px-4", TIMELINE_MAX_W)}
172
+ >
173
+ {error && (
174
+ <MessageScrollerItem messageId="error">
175
+ <p className="text-sm text-destructive">{error}</p>
176
+ </MessageScrollerItem>
177
+ )}
178
+ {groups.map((group) =>
179
+ group.kind === "user" ? (
180
+ <div
181
+ key={group.message.id}
182
+ ref={(el) => registerMarkEl(group.message.id, el)}
183
+ // `scroll-mt-4`: cadangan bila lompat dilakukan browser.
184
+ className="scroll-mt-4"
185
+ >
186
+ <UserMessage message={group.message} />
187
+ </div>
188
+ ) : (
189
+ <AssistantTurn
190
+ key={group.id}
191
+ group={group}
192
+ collapsible={collapsible}
193
+ onToggle={onToggle}
194
+ />
195
+ ),
196
+ )}
197
+ {/* Maskot GLOBAL — selalu menjadi item paling bawah di scroller,
198
+ ikut scroll bersama konten (ala Claude). Status:
199
+ - "Starting…" saat generating tapi respons belum datang
200
+ - mascotStatus (Working/Thinking/Writing/tool) saat ada turn aktif
201
+ - standby (logo diam) setelah selesai, selama ada riwayat */}
202
+ {messages.length > 0 && (
203
+ <MessageScrollerItem messageId="mascot">
204
+ <Mascot
205
+ label={generating ? (mascotStatus ?? "Starting…") : null}
206
+ active={generating}
207
+ standby
208
+ className="px-1"
209
+ />
210
+ </MessageScrollerItem>
211
+ )}
212
+ </MessageScrollerContent>
213
+ </MessageScrollerViewport>
214
+ {/* Rail penanda pesan user (tepi kiri area percakapan). */}
215
+ <MessageRail marks={marks} activeId={activeMark} onJump={jumpTo} />
216
+ <MessageScrollerButton />
217
+ </MessageScroller>
218
+ </MessageScrollerProvider>
219
+ );
220
+ }
221
+
222
+ interface AutoScrollFollowProps {
223
+ /** Perubahan pada prop berikut yang boleh memicu auto-follow ke bawah. */
224
+ messages: SessionMessage[];
225
+ status: SessionStatus;
226
+ error: string | null;
227
+ viewportRef: { current: HTMLDivElement | null };
228
+ }
229
+
230
+ /**
231
+ * Pengganti `autoScroll` bawaan MessageScroller (yang dimatikan di provider).
232
+ *
233
+ * Aturan UX:
234
+ * - Selama pengguna masih kurang dari satu layar di atas dasar percakapan
235
+ * (tombol panah belum muncul), isi timeline yang BERTAMBAH — respon baru /
236
+ * pesan streaming / catatan status — tetap memaksa scroll ke dasar.
237
+ * - Setelah scroll ke atas satu layar penuh atau lebih, konten baru TIDAK
238
+ * menarik layar (tombol panah yang mengembalikannya).
239
+ * - Membuka/menutup blok "Thought process" TIDAK memicu scroll: toggle hanya
240
+ * mengubah state collapsible (bukan `messages`/`status`/`error`), jadi efek
241
+ * ini tidak berjalan dan posisi baca pengguna tetap dipertahankan.
242
+ *
243
+ * Scroll hanya dijalankan bila tinggi konten benar-benar bertambah (perubahan
244
+ * pesan yang tidak mengubah layout — mis. part tool di blok yang masih
245
+ * collapsed — tidak boleh menarik layar).
246
+ */
247
+ function AutoScrollFollow({ messages, status, error, viewportRef }: AutoScrollFollowProps) {
248
+ const { scrollToEnd } = useMessageScroller();
249
+ /** scrollHeight dari commit terakhir — baseline deteksi konten bertambah. */
250
+ const lastContentHeightRef = useRef<number | null>(null);
251
+
252
+ // Dipicu hanya saat isi timeline berubah (pesan/status/error) — bukan saat
253
+ // toggle collapsible. Diletakkan SEBELUM efek baseline di bawah: di commit
254
+ // yang sama ia membaca tinggi konten dari commit SEBELUMNYA.
255
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `messages`/`status`/`error` sengaja jadi trigger — keputusan scroll dibaca dari DOM (scrollHeight/scrollTop), bukan dari nilai closure.
256
+ useLayoutEffect(() => {
257
+ const viewport = viewportRef.current;
258
+ if (viewport === null) return;
259
+ const previousHeight = lastContentHeightRef.current;
260
+ const grew = previousHeight === null || viewport.scrollHeight > previousHeight;
261
+ if (!grew) return;
262
+ const distanceToBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
263
+ if (distanceToBottom <= viewport.clientHeight) scrollToEnd();
264
+ }, [messages, status, error, viewportRef, scrollToEnd]);
265
+
266
+ // Baseline tinggi konten selalu diperbarui di setiap commit — termasuk saat
267
+ // toggle collapsible / resize — agar commit pesan berikutnya dibandingkan
268
+ // dengan tinggi yang benar.
269
+ useLayoutEffect(() => {
270
+ const viewport = viewportRef.current;
271
+ if (viewport !== null) lastContentHeightRef.current = viewport.scrollHeight;
272
+ });
273
+
274
+ return null;
275
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Kerangka loading percakapan Session.
3
+ *
4
+ * Dipakai di dua fase berurutan dengan tampilan IDENTIK agar tidak ada
5
+ * kedipan saat fase berganti:
6
+ * 1. `SessionPage` — data Session dimuat (`GET /api/sessions`).
7
+ * 2. `SessionView` — Session sudah ada, menunggu riwayat (`history`) dari
8
+ * WebSocket. Tanpa ini, `messages` kosong sesaat tampil sebagai
9
+ * empty-state "No conversation yet" padahal percakapannya ada.
10
+ *
11
+ * Bentuk meniru timeline asli (gelembung user di kanan, paragraf assistant
12
+ * di kiri) dengan lebar kolom yang sama (`TIMELINE_MAX_W`), lalu memudar
13
+ * masuk setelah jeda singkat — koneksi cepat tidak sempat melihat kilatan
14
+ * kerangka sama sekali.
15
+ */
16
+ import { Skeleton } from "@/components/ui/skeleton";
17
+ import { CONVERSATION_MAX_W, TIMELINE_MAX_W } from "@/lib/layout";
18
+ import { cn } from "@/lib/utils";
19
+
20
+ /** Satu turn: gelembung user + beberapa baris jawaban. */
21
+ const TURNS: { user: string; lines: string[] }[] = [
22
+ { user: "w-[46%]", lines: ["w-[92%]", "w-[84%]", "w-[58%]"] },
23
+ { user: "w-[32%]", lines: ["w-[88%]", "w-[70%]"] },
24
+ ];
25
+
26
+ export function SessionTimelineSkeleton({ className }: { className?: string }) {
27
+ return (
28
+ <div
29
+ role="status"
30
+ aria-live="polite"
31
+ aria-label="Loading conversation"
32
+ className={cn(
33
+ "min-h-0 flex-1 overflow-hidden",
34
+ // Muncul setelah 150ms: muatan cepat tidak berkedip.
35
+ "animate-in fade-in fill-mode-both delay-150 duration-300 motion-reduce:animate-none",
36
+ className,
37
+ )}
38
+ >
39
+ <div
40
+ className={cn(
41
+ "mx-auto flex w-full flex-col gap-6 px-3 py-4 sm:gap-8 sm:px-4",
42
+ TIMELINE_MAX_W,
43
+ )}
44
+ >
45
+ {TURNS.map((t, i) => (
46
+ // biome-ignore lint/suspicious/noArrayIndexKey: kerangka statis
47
+ <div key={i} className="flex flex-col gap-4">
48
+ <Skeleton className={cn("ml-auto h-11 rounded-2xl rounded-br-md", t.user)} />
49
+ <div className="flex flex-col gap-2.5 px-1">
50
+ {t.lines.map((w, j) => (
51
+ // biome-ignore lint/suspicious/noArrayIndexKey: kerangka statis
52
+ <Skeleton key={j} className={cn("h-3.5 rounded-full", w)} />
53
+ ))}
54
+ </div>
55
+ </div>
56
+ ))}
57
+ </div>
58
+ <span className="sr-only">Loading conversation…</span>
59
+ </div>
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Kerangka composer. Struktur & ukuran MENIRU `SessionComposer` (footer +
65
+ * kotak `rounded-xl border p-2` + satu baris setinggi `size-9`) sehingga
66
+ * tingginya identik (54px) dan tidak ada lompatan saat composer asli muncul.
67
+ */
68
+ export function SessionComposerSkeleton() {
69
+ return (
70
+ <div 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">
71
+ <div
72
+ className={cn(
73
+ "mx-auto flex w-full items-center gap-2 rounded-xl border bg-card/80 p-2 shadow-sm",
74
+ CONVERSATION_MAX_W,
75
+ )}
76
+ >
77
+ <Skeleton className="size-9 shrink-0 rounded-md" />
78
+ <Skeleton className="h-3.5 flex-1 rounded-full" />
79
+ <Skeleton className="size-9 shrink-0 rounded-md" />
80
+ </div>
81
+ </div>
82
+ );
83
+ }