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,137 @@
1
+ /**
2
+ * Satu baris Session di daftar (`SessionList`).
3
+ *
4
+ * Baris polos tanpa kotak: titik status, judul, meta tipis, waktu di kanan.
5
+ * Badan baris adalah tombol buka (target sentuh lebar); aksi sekunder ada di
6
+ * menu "..." — di perangkat dengan pointer halus (mouse) menu baru muncul
7
+ * saat hover/fokus agar daftar tetap bersih, di layar sentuh selalu tampil.
8
+ */
9
+ import { MoreHorizontalIcon, PlayIcon, SquareIcon, Trash2Icon } from "lucide-react";
10
+ import { Button } from "@/components/ui/button";
11
+ import {
12
+ DropdownMenu,
13
+ DropdownMenuContent,
14
+ DropdownMenuItem,
15
+ DropdownMenuSeparator,
16
+ DropdownMenuTrigger,
17
+ } from "@/components/ui/dropdown-menu";
18
+ import { Skeleton } from "@/components/ui/skeleton";
19
+ import { Spinner } from "@/components/ui/spinner";
20
+ import { formatRelativeTime } from "@/lib/project-overview";
21
+ import { SESSION_STATUS_DOT, SESSION_STATUS_LABEL } from "@/lib/session-status";
22
+ import { describeSessionModel } from "@/lib/session-summary";
23
+ import { displaySessionTitle } from "@/lib/session-title";
24
+ import { cn } from "@/lib/utils";
25
+ import type { Session } from "@/types";
26
+
27
+ export interface SessionRowProps {
28
+ session: Session;
29
+ /** Ada aksi berjalan untuk Session ini — kunci baris agar tidak dobel klik. */
30
+ busy: boolean;
31
+ onOpen: () => void;
32
+ onStop: () => void;
33
+ onStart: () => void;
34
+ onDelete: () => void;
35
+ }
36
+
37
+ /** Titik status; running diberi denyut halus. */
38
+ function StatusDot({ session, busy }: { session: Session; busy: boolean }) {
39
+ if (busy) return <Spinner className="size-3 text-muted-foreground" />;
40
+ const running = session.status === "running";
41
+ return (
42
+ <span className="relative flex size-2" aria-hidden>
43
+ {running && (
44
+ <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-50" />
45
+ )}
46
+ <span
47
+ className={cn(
48
+ "relative inline-flex size-2 rounded-full",
49
+ SESSION_STATUS_DOT[session.status],
50
+ )}
51
+ />
52
+ </span>
53
+ );
54
+ }
55
+
56
+ export function SessionRow({ session, busy, onOpen, onStop, onStart, onDelete }: SessionRowProps) {
57
+ const running = session.status === "running";
58
+ const title = displaySessionTitle(session.title);
59
+
60
+ return (
61
+ <div className="group relative flex items-center rounded-lg transition-colors hover:bg-muted/50 has-focus-visible:bg-muted/50 has-focus-visible:ring-[3px] has-focus-visible:ring-ring/50">
62
+ <button
63
+ type="button"
64
+ onClick={onOpen}
65
+ disabled={busy}
66
+ className="flex min-w-0 flex-1 items-center gap-3.5 rounded-lg px-3 py-3 text-left focus-visible:outline-none disabled:opacity-60"
67
+ aria-label={`Open session ${title}, ${SESSION_STATUS_LABEL[session.status]}`}
68
+ >
69
+ <span className="flex size-3 shrink-0 items-center justify-center">
70
+ <StatusDot session={session} busy={busy} />
71
+ </span>
72
+ <span className="flex min-w-0 flex-1 flex-col gap-0.5">
73
+ <span className="truncate text-[15px] font-medium leading-snug">{title}</span>
74
+ <span className="truncate text-xs text-muted-foreground">
75
+ {session.agentType}
76
+ <span className="mx-1.5 opacity-50">/</span>
77
+ {describeSessionModel(session)}
78
+ </span>
79
+ </span>
80
+ <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
81
+ {formatRelativeTime(session.updatedAt)}
82
+ </span>
83
+ </button>
84
+
85
+ <DropdownMenu>
86
+ <DropdownMenuTrigger asChild>
87
+ <Button
88
+ type="button"
89
+ variant="ghost"
90
+ size="icon-sm"
91
+ disabled={busy}
92
+ aria-label={`Session actions ${title}`}
93
+ className="mr-1.5 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"
94
+ >
95
+ <MoreHorizontalIcon />
96
+ </Button>
97
+ </DropdownMenuTrigger>
98
+ <DropdownMenuContent align="end">
99
+ {running ? (
100
+ <DropdownMenuItem onSelect={onStop}>
101
+ <SquareIcon />
102
+ Stop
103
+ </DropdownMenuItem>
104
+ ) : (
105
+ <DropdownMenuItem onSelect={onStart}>
106
+ <PlayIcon />
107
+ Start
108
+ </DropdownMenuItem>
109
+ )}
110
+ <DropdownMenuSeparator />
111
+ <DropdownMenuItem variant="destructive" onSelect={onDelete}>
112
+ <Trash2Icon />
113
+ Delete permanently
114
+ </DropdownMenuItem>
115
+ </DropdownMenuContent>
116
+ </DropdownMenu>
117
+ </div>
118
+ );
119
+ }
120
+
121
+ /** Placeholder daftar Session saat memuat — menjaga tinggi konten. */
122
+ export function SessionListSkeleton() {
123
+ return (
124
+ <div className="flex flex-col" aria-hidden>
125
+ {[0, 1, 2].map((i) => (
126
+ <div key={i} className="flex items-center gap-3.5 py-3">
127
+ <Skeleton className="size-2 shrink-0 rounded-full" />
128
+ <div className="flex min-w-0 flex-1 flex-col gap-1.5">
129
+ <Skeleton className="h-4 w-48" />
130
+ <Skeleton className="h-3 w-32" />
131
+ </div>
132
+ <Skeleton className="h-3 w-16" />
133
+ </div>
134
+ ))}
135
+ </div>
136
+ );
137
+ }
@@ -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
+ }