@jcjeon/integration-cli 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 (264) hide show
  1. package/.gitignore +23 -0
  2. package/.npmignore +21 -0
  3. package/.prettierignore +6 -0
  4. package/.prettierrc +26 -0
  5. package/AGENTS.md +10 -0
  6. package/CLAUDE.md +10 -0
  7. package/README.md +384 -0
  8. package/apps/server/README.md +294 -0
  9. package/apps/server/eslint.config.mjs +20 -0
  10. package/apps/server/nest-cli.json +8 -0
  11. package/apps/server/package.json +89 -0
  12. package/apps/server/scripts/postinstall.js +53 -0
  13. package/apps/server/src/__mocks__/glob.js +6 -0
  14. package/apps/server/src/__mocks__/uuid.js +5 -0
  15. package/apps/server/src/app.controller.spec.ts +24 -0
  16. package/apps/server/src/app.controller.ts +13 -0
  17. package/apps/server/src/app.module.ts +18 -0
  18. package/apps/server/src/app.service.ts +8 -0
  19. package/apps/server/src/common/ji-paths.ts +41 -0
  20. package/apps/server/src/database/database.module.ts +27 -0
  21. package/apps/server/src/database/entities/agent-changelog.entity.ts +39 -0
  22. package/apps/server/src/database/entities/agent-session.entity.ts +29 -0
  23. package/apps/server/src/database/entities/conversation.entity.ts +41 -0
  24. package/apps/server/src/database/entities/session.entity.ts +16 -0
  25. package/apps/server/src/database/entities/task-agent-run.entity.ts +40 -0
  26. package/apps/server/src/database/entities/task-agent.entity.ts +42 -0
  27. package/apps/server/src/database/entities/task-requirement.entity.ts +27 -0
  28. package/apps/server/src/database/entities/task-run.entity.ts +41 -0
  29. package/apps/server/src/database/entities/task.entity.ts +44 -0
  30. package/apps/server/src/main.ts +65 -0
  31. package/apps/server/src/modules/agents/agent-model-settings.spec.ts +80 -0
  32. package/apps/server/src/modules/agents/agents.module.ts +11 -0
  33. package/apps/server/src/modules/agents/claude/claude-auth.manager.ts +83 -0
  34. package/apps/server/src/modules/agents/claude/claude-pty.manager.ts +380 -0
  35. package/apps/server/src/modules/agents/claude/claude.controller.ts +85 -0
  36. package/apps/server/src/modules/agents/claude/claude.gateway.ts +158 -0
  37. package/apps/server/src/modules/agents/claude/claude.module.ts +18 -0
  38. package/apps/server/src/modules/agents/claude/claude.service.ts +67 -0
  39. package/apps/server/src/modules/agents/claude/dto/create-session.dto.ts +24 -0
  40. package/apps/server/src/modules/agents/claude/dto/resize-session.dto.ts +13 -0
  41. package/apps/server/src/modules/agents/claude/dto/send-input.dto.ts +9 -0
  42. package/apps/server/src/modules/agents/claude/interfaces/claude-session.interface.ts +26 -0
  43. package/apps/server/src/modules/agents/claude/interfaces/pty-event.interface.ts +10 -0
  44. package/apps/server/src/modules/agents/claude/interfaces/stream-event.interface.ts +61 -0
  45. package/apps/server/src/modules/agents/codex/codex-auth.manager.ts +107 -0
  46. package/apps/server/src/modules/agents/codex/codex-session.manager.ts +357 -0
  47. package/apps/server/src/modules/agents/codex/codex.controller.ts +64 -0
  48. package/apps/server/src/modules/agents/codex/codex.gateway.ts +97 -0
  49. package/apps/server/src/modules/agents/codex/codex.module.ts +17 -0
  50. package/apps/server/src/modules/agents/codex/dto/configure-auth.dto.ts +7 -0
  51. package/apps/server/src/modules/agents/gemini/dto/configure-auth.dto.ts +15 -0
  52. package/apps/server/src/modules/agents/gemini/dto/create-session.dto.ts +9 -0
  53. package/apps/server/src/modules/agents/gemini/dto/send-input.dto.ts +9 -0
  54. package/apps/server/src/modules/agents/gemini/gemini-auth.manager.ts +157 -0
  55. package/apps/server/src/modules/agents/gemini/gemini-session.manager.ts +287 -0
  56. package/apps/server/src/modules/agents/gemini/gemini.controller.ts +93 -0
  57. package/apps/server/src/modules/agents/gemini/gemini.gateway.ts +149 -0
  58. package/apps/server/src/modules/agents/gemini/gemini.module.ts +17 -0
  59. package/apps/server/src/modules/agents/gemini/interfaces/gemini-session.interface.ts +18 -0
  60. package/apps/server/src/modules/agents/gemini/interfaces/stream-event.interface.ts +14 -0
  61. package/apps/server/src/modules/agents/session-termination.spec.ts +103 -0
  62. package/apps/server/src/modules/changelog/changelog.controller.ts +20 -0
  63. package/apps/server/src/modules/changelog/changelog.module.ts +14 -0
  64. package/apps/server/src/modules/changelog/changelog.service.spec.ts +531 -0
  65. package/apps/server/src/modules/changelog/changelog.service.ts +690 -0
  66. package/apps/server/src/modules/conversations/conversation.controller.spec.ts +106 -0
  67. package/apps/server/src/modules/conversations/conversation.controller.ts +60 -0
  68. package/apps/server/src/modules/conversations/conversation.module.ts +14 -0
  69. package/apps/server/src/modules/conversations/conversation.service.spec.ts +176 -0
  70. package/apps/server/src/modules/conversations/conversation.service.ts +54 -0
  71. package/apps/server/src/modules/conversations/dto/create-conversation.dto.ts +37 -0
  72. package/apps/server/src/modules/conversations/enums/conversation.enum.ts +13 -0
  73. package/apps/server/src/modules/fs/fs.controller.ts +29 -0
  74. package/apps/server/src/modules/fs/fs.module.ts +8 -0
  75. package/apps/server/src/modules/harness/dto/save-harness.dto.ts +9 -0
  76. package/apps/server/src/modules/harness/harness.controller.spec.ts +95 -0
  77. package/apps/server/src/modules/harness/harness.controller.ts +35 -0
  78. package/apps/server/src/modules/harness/harness.module.ts +11 -0
  79. package/apps/server/src/modules/harness/harness.service.spec.ts +217 -0
  80. package/apps/server/src/modules/harness/harness.service.ts +112 -0
  81. package/apps/server/src/modules/sessions/session.controller.spec.ts +68 -0
  82. package/apps/server/src/modules/sessions/session.controller.ts +43 -0
  83. package/apps/server/src/modules/sessions/session.module.ts +14 -0
  84. package/apps/server/src/modules/sessions/session.service.spec.ts +106 -0
  85. package/apps/server/src/modules/sessions/session.service.ts +35 -0
  86. package/apps/server/src/modules/tasks/dto/create-task.dto.ts +54 -0
  87. package/apps/server/src/modules/tasks/dto/execute-task.dto.ts +22 -0
  88. package/apps/server/src/modules/tasks/dto/merge-file.dto.ts +7 -0
  89. package/apps/server/src/modules/tasks/dto/rerun-task.dto.ts +14 -0
  90. package/apps/server/src/modules/tasks/dto/update-task.dto.ts +55 -0
  91. package/apps/server/src/modules/tasks/task-execution.service.ts +978 -0
  92. package/apps/server/src/modules/tasks/task.gateway.ts +140 -0
  93. package/apps/server/src/modules/tasks/tasks.controller.spec.ts +210 -0
  94. package/apps/server/src/modules/tasks/tasks.controller.ts +139 -0
  95. package/apps/server/src/modules/tasks/tasks.module.ts +30 -0
  96. package/apps/server/src/modules/tasks/tasks.service.spec.ts +552 -0
  97. package/apps/server/src/modules/tasks/tasks.service.ts +333 -0
  98. package/apps/server/test/app.e2e-spec.ts +28 -0
  99. package/apps/server/test/jest-e2e.json +9 -0
  100. package/apps/server/tsconfig.build.json +4 -0
  101. package/apps/server/tsconfig.json +13 -0
  102. package/apps/web/AGENTS.md +7 -0
  103. package/apps/web/CLAUDE.md +1 -0
  104. package/apps/web/README.md +36 -0
  105. package/apps/web/eslint.config.mjs +21 -0
  106. package/apps/web/next-env.d.ts +6 -0
  107. package/apps/web/next.config.ts +7 -0
  108. package/apps/web/package.json +49 -0
  109. package/apps/web/postcss.config.mjs +7 -0
  110. package/apps/web/public/file.svg +1 -0
  111. package/apps/web/public/globe.svg +1 -0
  112. package/apps/web/public/next.svg +1 -0
  113. package/apps/web/public/vercel.svg +1 -0
  114. package/apps/web/public/window.svg +1 -0
  115. package/apps/web/src/app/claude/page.tsx +5 -0
  116. package/apps/web/src/app/codex/page.tsx +126 -0
  117. package/apps/web/src/app/favicon.ico +0 -0
  118. package/apps/web/src/app/gemini/page.tsx +130 -0
  119. package/apps/web/src/app/globals.css +149 -0
  120. package/apps/web/src/app/layout.tsx +40 -0
  121. package/apps/web/src/app/login/page.tsx +67 -0
  122. package/apps/web/src/app/page.tsx +497 -0
  123. package/apps/web/src/app/task/[id]/page.tsx +11 -0
  124. package/apps/web/src/app/test/page.tsx +298 -0
  125. package/apps/web/src/components/ui/Modal.tsx +78 -0
  126. package/apps/web/src/components/ui/WorkingDirPicker.tsx +195 -0
  127. package/apps/web/src/components/ui/__tests__/Modal.test.tsx +68 -0
  128. package/apps/web/src/features/auth/api/__tests__/auth.api.test.ts +83 -0
  129. package/apps/web/src/features/auth/api/auth.api.ts +81 -0
  130. package/apps/web/src/features/auth/hooks/__tests__/useClaudeAuth.test.ts +166 -0
  131. package/apps/web/src/features/auth/hooks/__tests__/useCodexAuth.test.ts +127 -0
  132. package/apps/web/src/features/auth/hooks/__tests__/useGeminiAuth.test.ts +120 -0
  133. package/apps/web/src/features/auth/hooks/useClaudeAuth.ts +88 -0
  134. package/apps/web/src/features/auth/hooks/useCodexAuth.ts +149 -0
  135. package/apps/web/src/features/auth/hooks/useGeminiAuth.ts +125 -0
  136. package/apps/web/src/features/auth/ui/CodexLoginPanel.tsx +302 -0
  137. package/apps/web/src/features/auth/ui/GeminiLoginPanel.tsx +316 -0
  138. package/apps/web/src/features/auth/ui/LoginForm.tsx +190 -0
  139. package/apps/web/src/features/auth/ui/LoginPanel.tsx +114 -0
  140. package/apps/web/src/features/auth/ui/__tests__/LoginPanel.test.tsx +105 -0
  141. package/apps/web/src/features/chat/api/__tests__/sessions.api.test.ts +187 -0
  142. package/apps/web/src/features/chat/api/sessions.api.ts +161 -0
  143. package/apps/web/src/features/chat/container/ClaudePageContainer.tsx +152 -0
  144. package/apps/web/src/features/chat/hooks/__tests__/useCodexSessions.test.ts +131 -0
  145. package/apps/web/src/features/chat/hooks/__tests__/useGeminiSessions.test.ts +130 -0
  146. package/apps/web/src/features/chat/hooks/useAgentModelSettings.ts +54 -0
  147. package/apps/web/src/features/chat/hooks/useClaudeSessions.ts +323 -0
  148. package/apps/web/src/features/chat/hooks/useCodexSessions.ts +275 -0
  149. package/apps/web/src/features/chat/hooks/useGeminiSessions.ts +255 -0
  150. package/apps/web/src/features/chat/hooks/useSessionCommand.ts +66 -0
  151. package/apps/web/src/features/chat/hooks/useSessionRename.ts +61 -0
  152. package/apps/web/src/features/chat/hooks/useSessionWorkingDirectories.ts +34 -0
  153. package/apps/web/src/features/chat/hooks/useUnifiedSessions.ts +156 -0
  154. package/apps/web/src/features/chat/lib/agentModelOptions.ts +72 -0
  155. package/apps/web/src/features/chat/ui/AgentModelPicker.tsx +134 -0
  156. package/apps/web/src/features/chat/ui/AgentSelectModal.tsx +236 -0
  157. package/apps/web/src/features/chat/ui/ChatInput.tsx +162 -0
  158. package/apps/web/src/features/chat/ui/ChatMessage.tsx +204 -0
  159. package/apps/web/src/features/chat/ui/ChatWorkspace.tsx +207 -0
  160. package/apps/web/src/features/chat/ui/CheckingSkeleton.tsx +44 -0
  161. package/apps/web/src/features/chat/ui/ClaudeLoginView.tsx +44 -0
  162. package/apps/web/src/features/chat/ui/PermissionCard.tsx +37 -0
  163. package/apps/web/src/features/chat/ui/SessionSidebar.tsx +280 -0
  164. package/apps/web/src/features/chat/ui/__tests__/AgentSelectModal.test.tsx +58 -0
  165. package/apps/web/src/features/chat/ui/__tests__/ChatInput.test.tsx +134 -0
  166. package/apps/web/src/features/chat/ui/__tests__/ChatMessage.test.tsx +106 -0
  167. package/apps/web/src/features/chat/ui/__tests__/ChatWorkspace.test.tsx +66 -0
  168. package/apps/web/src/features/diff/ui/DiffFileRow.tsx +73 -0
  169. package/apps/web/src/features/diff/ui/DiffHunk.tsx +61 -0
  170. package/apps/web/src/features/diff/ui/FileChangeBadge.tsx +23 -0
  171. package/apps/web/src/features/diff/ui/__tests__/DiffFileRow.test.tsx +40 -0
  172. package/apps/web/src/features/diff/ui/__tests__/DiffHunk.test.tsx +24 -0
  173. package/apps/web/src/features/diff/ui/__tests__/FileChangeBadge.test.tsx +16 -0
  174. package/apps/web/src/features/fs/api/fs.api.ts +14 -0
  175. package/apps/web/src/features/fs/hooks/useDirBrowser.ts +50 -0
  176. package/apps/web/src/features/harness/api/__tests__/harness.api.test.ts +73 -0
  177. package/apps/web/src/features/harness/api/harness.api.ts +46 -0
  178. package/apps/web/src/features/harness/hooks/__tests__/useHarness.test.ts +65 -0
  179. package/apps/web/src/features/harness/hooks/useHarness.ts +66 -0
  180. package/apps/web/src/features/harness/ui/HarnessModal.tsx +171 -0
  181. package/apps/web/src/features/harness/ui/__tests__/HarnessModal.test.tsx +46 -0
  182. package/apps/web/src/features/status/ui/AgentStatusModal.tsx +267 -0
  183. package/apps/web/src/features/status/ui/__tests__/AgentStatusModal.test.tsx +71 -0
  184. package/apps/web/src/features/tasks/api/__tests__/changelog.api.test.ts +89 -0
  185. package/apps/web/src/features/tasks/api/__tests__/tasks.api.test.ts +282 -0
  186. package/apps/web/src/features/tasks/api/changelog.api.ts +52 -0
  187. package/apps/web/src/features/tasks/api/tasks.api.ts +175 -0
  188. package/apps/web/src/features/tasks/container/TaskDetailPageContainer.tsx +69 -0
  189. package/apps/web/src/features/tasks/hooks/__tests__/useChangelogCodeCopy.test.ts +48 -0
  190. package/apps/web/src/features/tasks/hooks/__tests__/useTaskChangelog.test.ts +48 -0
  191. package/apps/web/src/features/tasks/hooks/__tests__/useTaskCreate.test.ts +217 -0
  192. package/apps/web/src/features/tasks/hooks/__tests__/useTaskEdit.test.ts +152 -0
  193. package/apps/web/src/features/tasks/hooks/__tests__/useTaskExecution.test.ts +143 -0
  194. package/apps/web/src/features/tasks/hooks/__tests__/useTaskList.test.ts +168 -0
  195. package/apps/web/src/features/tasks/hooks/__tests__/useTaskNotification.test.ts +125 -0
  196. package/apps/web/src/features/tasks/hooks/__tests__/useTaskRuns.test.ts +51 -0
  197. package/apps/web/src/features/tasks/hooks/useChangelogCodeCopy.ts +52 -0
  198. package/apps/web/src/features/tasks/hooks/useCopyToClipboard.ts +47 -0
  199. package/apps/web/src/features/tasks/hooks/useTaskChangelog.ts +32 -0
  200. package/apps/web/src/features/tasks/hooks/useTaskCreate.ts +137 -0
  201. package/apps/web/src/features/tasks/hooks/useTaskDetail.ts +217 -0
  202. package/apps/web/src/features/tasks/hooks/useTaskEdit.ts +130 -0
  203. package/apps/web/src/features/tasks/hooks/useTaskExecution.ts +137 -0
  204. package/apps/web/src/features/tasks/hooks/useTaskList.ts +159 -0
  205. package/apps/web/src/features/tasks/hooks/useTaskNotification.ts +80 -0
  206. package/apps/web/src/features/tasks/hooks/useTaskRuns.ts +32 -0
  207. package/apps/web/src/features/tasks/ui/AgentOutputPanel.tsx +203 -0
  208. package/apps/web/src/features/tasks/ui/AgentRoleSelect.tsx +97 -0
  209. package/apps/web/src/features/tasks/ui/ChangelogPanel.tsx +321 -0
  210. package/apps/web/src/features/tasks/ui/RunHistoryPanel.tsx +193 -0
  211. package/apps/web/src/features/tasks/ui/TaskCreateModal.tsx +205 -0
  212. package/apps/web/src/features/tasks/ui/TaskDetailView.tsx +413 -0
  213. package/apps/web/src/features/tasks/ui/TaskEditModal.tsx +165 -0
  214. package/apps/web/src/features/tasks/ui/TaskListModal.tsx +591 -0
  215. package/apps/web/src/features/tasks/ui/__tests__/AgentRoleSelect.test.tsx +91 -0
  216. package/apps/web/src/features/tasks/ui/__tests__/ChangelogPanel.test.tsx +94 -0
  217. package/apps/web/src/features/tasks/ui/__tests__/RunHistoryPanel.test.tsx +71 -0
  218. package/apps/web/src/features/tasks/ui/__tests__/TaskCreateModal.test.tsx +153 -0
  219. package/apps/web/src/features/tasks/ui/__tests__/TaskEditModal.test.tsx +75 -0
  220. package/apps/web/src/features/tasks/ui/__tests__/TaskListModal.test.tsx +243 -0
  221. package/apps/web/src/hooks/useWorkingDir.ts +28 -0
  222. package/apps/web/src/lib/__tests__/ansi.test.ts +88 -0
  223. package/apps/web/src/lib/ansi.ts +105 -0
  224. package/apps/web/src/lib/constants.ts +4 -0
  225. package/apps/web/src/lib/quota.ts +22 -0
  226. package/apps/web/src/lib/theme.tsx +78 -0
  227. package/apps/web/src/lib/toast.tsx +175 -0
  228. package/apps/web/src/store/agentStatusStore.ts +38 -0
  229. package/apps/web/tsconfig.json +18 -0
  230. package/apps/web/vitest.config.ts +25 -0
  231. package/apps/web/vitest.setup.ts +10 -0
  232. package/package.json +85 -0
  233. package/packages/cli/dist/commands/check.d.ts +1 -0
  234. package/packages/cli/dist/commands/check.js +89 -0
  235. package/packages/cli/dist/commands/init.d.ts +5 -0
  236. package/packages/cli/dist/commands/init.js +183 -0
  237. package/packages/cli/dist/commands/start.d.ts +4 -0
  238. package/packages/cli/dist/commands/start.js +188 -0
  239. package/packages/cli/dist/index.d.ts +2 -0
  240. package/packages/cli/dist/index.js +71 -0
  241. package/packages/cli/dist/utils/agent-tools.d.ts +28 -0
  242. package/packages/cli/dist/utils/agent-tools.js +193 -0
  243. package/packages/cli/dist/utils/project-init.d.ts +12 -0
  244. package/packages/cli/dist/utils/project-init.js +258 -0
  245. package/packages/cli/dist/utils/proxy.d.ts +8 -0
  246. package/packages/cli/dist/utils/proxy.js +138 -0
  247. package/packages/cli/package.json +30 -0
  248. package/packages/cli/src/commands/check.ts +77 -0
  249. package/packages/cli/src/commands/init.ts +209 -0
  250. package/packages/cli/src/commands/start.ts +183 -0
  251. package/packages/cli/src/index.ts +91 -0
  252. package/packages/cli/src/utils/agent-tools.ts +201 -0
  253. package/packages/cli/src/utils/project-init.ts +252 -0
  254. package/packages/cli/src/utils/proxy.ts +123 -0
  255. package/packages/cli/tsconfig.json +14 -0
  256. package/packages/eslint-config/base.mjs +31 -0
  257. package/packages/eslint-config/nest.mjs +55 -0
  258. package/packages/eslint-config/next.mjs +23 -0
  259. package/packages/eslint-config/package.json +20 -0
  260. package/packages/typescript-config/base.json +16 -0
  261. package/packages/typescript-config/nestjs.json +17 -0
  262. package/packages/typescript-config/nextjs.json +15 -0
  263. package/packages/typescript-config/package.json +11 -0
  264. package/turbo.json +28 -0
@@ -0,0 +1,37 @@
1
+ interface Props {
2
+ tool: string;
3
+ command: string;
4
+ warning?: string;
5
+ onAllow: () => void;
6
+ onDeny: () => void;
7
+ }
8
+
9
+ export function PermissionCard({ tool, command, warning, onAllow, onDeny }: Props) {
10
+ return (
11
+ <div className="flex w-full justify-start gap-3">
12
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-orange-500/80 text-xs font-bold text-white">C</div>
13
+ <div className="max-w-[75%] min-w-0 rounded-2xl rounded-tl-sm border border-amber-500/25 bg-amber-500/[0.06] px-4 py-3 text-sm">
14
+ <p className="mb-2 font-semibold text-amber-700 dark:text-amber-300/90">실행 권한 요청</p>
15
+ <div className="mb-3 rounded-lg border border-gray-900/[0.06] bg-gray-900/[0.03] px-3 py-2 font-mono text-xs text-gray-900/70 dark:border-white/[0.06] dark:bg-white/[0.03] dark:text-white/70">
16
+ <span className="mr-2 text-gray-900/30 dark:text-white/30">{tool}</span>
17
+ {command}
18
+ </div>
19
+ {warning && <p className="mb-3 text-xs text-amber-600/80 dark:text-amber-400/70">⚠ {warning}</p>}
20
+ <div className="flex gap-2">
21
+ <button
22
+ onClick={onAllow}
23
+ className="rounded-lg border border-emerald-500/25 bg-emerald-500/[0.10] px-3 py-1.5 text-xs font-medium text-emerald-700 transition-colors hover:bg-emerald-500/[0.18] hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300"
24
+ >
25
+ 허용
26
+ </button>
27
+ <button
28
+ onClick={onDeny}
29
+ className="rounded-lg border border-gray-900/[0.08] bg-gray-900/[0.03] px-3 py-1.5 text-xs font-medium text-gray-900/45 transition-colors hover:bg-gray-900/[0.06] hover:text-gray-900/70 dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-white/45 dark:hover:bg-white/[0.06] dark:hover:text-white/70"
30
+ >
31
+ 거부
32
+ </button>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ );
37
+ }
@@ -0,0 +1,280 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import type { Dispatch, RefObject, SetStateAction } from "react";
5
+
6
+ import { isQuotaExceeded } from "@/lib/quota";
7
+ import { ThemeToggle } from "@/lib/theme";
8
+ import { AGENT_META } from "./AgentSelectModal";
9
+ import type { ConnectionStatus, UnifiedSessionState } from "../hooks/useUnifiedSessions";
10
+
11
+ const STATUS_DOT: Record<ConnectionStatus, string> = {
12
+ connected: "bg-emerald-400 shadow-[0_0_5px_#34d399]",
13
+ connecting: "bg-amber-400 animate-pulse",
14
+ disconnected: "bg-red-500",
15
+ };
16
+
17
+ const STATUS_LABEL: Record<ConnectionStatus, string> = {
18
+ connected: "연결됨",
19
+ connecting: "연결 중…",
20
+ disconnected: "연결 끊김",
21
+ };
22
+
23
+ interface SessionSidebarProps {
24
+ sessions: UnifiedSessionState[];
25
+ selectedSessionId: string | null;
26
+ overallConnectionStatus: ConnectionStatus;
27
+ hasNewTask: boolean;
28
+ menuOpenId: string | null;
29
+ renamingId: string | null;
30
+ renameValue: string;
31
+ menuRef: RefObject<HTMLDivElement | null>;
32
+ onSelectSession: (sessionId: string) => void;
33
+ onOpenAgentSelect: () => void;
34
+ onOpenTaskCreate: () => void;
35
+ onOpenTaskList: () => void;
36
+ onOpenStatus: () => void;
37
+ onOpenHarness: () => void;
38
+ onSetMenuOpenId: Dispatch<SetStateAction<string | null>>;
39
+ onStartRename: (sessionId: string, currentTitle: string) => void;
40
+ onRenameValueChange: (value: string) => void;
41
+ onConfirmRename: () => void;
42
+ onCancelRename: () => void;
43
+ }
44
+
45
+ export function SessionSidebar({
46
+ sessions,
47
+ selectedSessionId,
48
+ overallConnectionStatus,
49
+ hasNewTask,
50
+ menuOpenId,
51
+ renamingId,
52
+ renameValue,
53
+ menuRef,
54
+ onSelectSession,
55
+ onOpenAgentSelect,
56
+ onOpenTaskCreate,
57
+ onOpenTaskList,
58
+ onOpenStatus,
59
+ onOpenHarness,
60
+ onSetMenuOpenId,
61
+ onStartRename,
62
+ onRenameValueChange,
63
+ onConfirmRename,
64
+ onCancelRename,
65
+ }: SessionSidebarProps) {
66
+ return (
67
+ <aside className="flex w-64 flex-shrink-0 flex-col border-r border-gray-900/[0.07] dark:border-white/[0.07]">
68
+ <div className="flex items-center gap-2 border-b border-gray-900/[0.07] px-4 py-3 dark:border-white/[0.07]">
69
+ <Link href="/" className="text-gray-900/30 transition-colors hover:text-gray-900/60 dark:text-white/30 dark:hover:text-white/60">
70
+
71
+ </Link>
72
+ <span className="text-sm font-semibold text-gray-900/80 dark:text-white/80">JI CLI</span>
73
+ <div className="ml-auto flex items-center gap-1.5">
74
+ <button
75
+ type="button"
76
+ onClick={onOpenStatus}
77
+ title="에이전트 상태"
78
+ className="flex items-center gap-1 rounded-md px-1.5 py-0.5 transition-colors hover:bg-gray-900/[0.05] dark:hover:bg-white/[0.05]"
79
+ >
80
+ <span className={`h-2 w-2 rounded-full ${STATUS_DOT[overallConnectionStatus]}`} />
81
+ <span className="text-xs text-gray-900/25 hover:text-gray-900/50 dark:text-white/25 dark:hover:text-white/50">
82
+ {STATUS_LABEL[overallConnectionStatus]}
83
+ </span>
84
+ </button>
85
+ <ThemeToggle />
86
+ </div>
87
+ </div>
88
+
89
+ <div className="flex flex-col gap-2 p-3">
90
+ <button
91
+ type="button"
92
+ onClick={onOpenAgentSelect}
93
+ disabled={overallConnectionStatus !== "connected"}
94
+ className="w-full rounded-lg bg-orange-600 py-2 text-sm font-medium text-white transition-colors hover:bg-orange-500 disabled:cursor-not-allowed disabled:opacity-40"
95
+ >
96
+ + 새 세션
97
+ </button>
98
+
99
+ <div className="flex gap-2">
100
+ <button
101
+ type="button"
102
+ onClick={onOpenTaskCreate}
103
+ className="flex-1 rounded-lg border border-gray-900/[0.08] bg-gray-900/[0.03] py-2 text-sm font-medium text-gray-900/55 transition-colors hover:border-gray-900/[0.14] hover:bg-gray-900/[0.05] hover:text-gray-900/85 dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-white/55 dark:hover:border-white/[0.14] dark:hover:bg-white/[0.05] dark:hover:text-white/85"
104
+ >
105
+ + 작업 추가
106
+ </button>
107
+ <button
108
+ type="button"
109
+ onClick={onOpenTaskList}
110
+ title="작업 목록"
111
+ className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-900/[0.08] bg-gray-900/[0.03] text-gray-900/35 transition-colors hover:border-gray-900/[0.14] hover:bg-gray-900/[0.05] hover:text-gray-900/70 dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-white/35 dark:hover:border-white/[0.14] dark:hover:bg-white/[0.05] dark:hover:text-white/70"
112
+ >
113
+ <svg viewBox="0 0 16 16" fill="currentColor" className="h-4 w-4">
114
+ <path fillRule="evenodd" d="M2 4.75A.75.75 0 012.75 4h10.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4.75zm0 3.5A.75.75 0 012.75 7.5h10.5a.75.75 0 010 1.5H2.75A.75.75 0 012 8.25zm0 3.5A.75.75 0 012.75 11h10.5a.75.75 0 010 1.5H2.75A.75.75 0 012 11.75z" clipRule="evenodd" />
115
+ </svg>
116
+ {hasNewTask && (
117
+ <span className="absolute right-0 top-0 h-2 w-2 rounded-full bg-orange-500 ring-2 ring-[#faf8f5] dark:ring-[#07090e]" />
118
+ )}
119
+ </button>
120
+ </div>
121
+ </div>
122
+
123
+ <nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
124
+ {sessions.length === 0 ? (
125
+ <p className="py-8 text-center text-xs text-gray-900/20 dark:text-white/20">
126
+ {overallConnectionStatus === "connected" ? "세션이 없습니다" : "연결 중…"}
127
+ </p>
128
+ ) : (
129
+ <ul className="flex flex-col gap-0.5">
130
+ {sessions.map((session) => {
131
+ const lastMsg = session.messages[session.messages.length - 1];
132
+ const isSelected = selectedSessionId === session.info.id;
133
+ const agentMeta = AGENT_META[session.agentId];
134
+ const quotaDetected =
135
+ isQuotaExceeded(session.streaming) ||
136
+ (!!lastMsg && isQuotaExceeded(lastMsg.content));
137
+ const isRenaming = renamingId === session.info.id;
138
+ const isMenuOpen = menuOpenId === session.info.id;
139
+
140
+ return (
141
+ <li key={session.info.id} className="group relative">
142
+ {isRenaming ? (
143
+ <div className={[
144
+ "rounded-lg px-3 py-2",
145
+ isSelected
146
+ ? "bg-gray-900/[0.06] dark:bg-white/[0.06]"
147
+ : "bg-gray-900/[0.03] dark:bg-white/[0.03]",
148
+ ].join(" ")}>
149
+ <div className="flex items-center gap-1.5 pl-0">
150
+ <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${agentMeta.dotColor}`} />
151
+ <input
152
+ autoFocus
153
+ value={renameValue}
154
+ onChange={(e) => onRenameValueChange(e.target.value)}
155
+ onKeyDown={(e) => {
156
+ if (e.key === "Enter") {
157
+ e.preventDefault();
158
+ onConfirmRename();
159
+ }
160
+ if (e.key === "Escape") onCancelRename();
161
+ }}
162
+ onBlur={onConfirmRename}
163
+ className="min-w-0 flex-1 border-b border-orange-500/50 bg-transparent pb-px text-xs font-medium text-gray-900/90 outline-none dark:text-white/90"
164
+ />
165
+ </div>
166
+ <p className="mt-0.5 pl-3 text-[10px] text-gray-900/20 dark:text-white/20">
167
+ Enter로 저장 · Esc로 취소
168
+ </p>
169
+ </div>
170
+ ) : (
171
+ <button
172
+ type="button"
173
+ onClick={() => onSelectSession(session.info.id)}
174
+ className={[
175
+ "w-full rounded-lg px-3 py-2.5 text-left transition-colors",
176
+ isSelected
177
+ ? "bg-gray-900/[0.06] text-gray-900/90 dark:bg-white/[0.06] dark:text-white/90"
178
+ : "text-gray-900/40 hover:bg-gray-900/[0.03] hover:text-gray-900/70 dark:text-white/40 dark:hover:bg-white/[0.03] dark:hover:text-white/70",
179
+ ].join(" ")}
180
+ >
181
+ <div className="flex items-center justify-between gap-1">
182
+ <div className="flex min-w-0 items-center gap-1.5 pr-5">
183
+ <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${agentMeta.dotColor}`} />
184
+ <span className="truncate text-xs font-medium">{session.info.title}</span>
185
+ </div>
186
+ {quotaDetected ? (
187
+ <span className="flex shrink-0 items-center gap-0.5 rounded-full border border-amber-500/30 bg-amber-500/[0.08] px-1.5 py-0.5 text-[9px] font-medium text-amber-600 dark:text-amber-400">
188
+ ⚠ 한도
189
+ </span>
190
+ ) : session.isWaiting ? (
191
+ <span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-orange-400" />
192
+ ) : null}
193
+ </div>
194
+ <p className="mt-0.5 pl-3 text-[10px] text-gray-900/20 dark:text-white/20">
195
+ {agentMeta.label} · {new Date(session.info.createdAt).toLocaleString()}
196
+ </p>
197
+ {lastMsg && (
198
+ <p className="mt-0.5 pl-3 truncate text-[11px] text-gray-900/25 dark:text-white/25">
199
+ {lastMsg.content.slice(0, 40) || "…"}
200
+ </p>
201
+ )}
202
+ </button>
203
+ )}
204
+
205
+ {!isRenaming && (
206
+ <div
207
+ ref={isMenuOpen ? menuRef : undefined}
208
+ className="absolute right-1.5 top-2 z-10"
209
+ >
210
+ <button
211
+ type="button"
212
+ onClick={(e) => {
213
+ e.stopPropagation();
214
+ onSetMenuOpenId(isMenuOpen ? null : session.info.id);
215
+ }}
216
+ className={[
217
+ "flex h-5 w-5 items-center justify-center rounded transition-all",
218
+ "text-gray-900/30 hover:bg-gray-900/[0.08] hover:text-gray-900/70",
219
+ "dark:text-white/30 dark:hover:bg-white/[0.08] dark:hover:text-white/70",
220
+ isMenuOpen
221
+ ? "bg-gray-900/[0.06] opacity-100 dark:bg-white/[0.06]"
222
+ : "opacity-0 group-hover:opacity-100",
223
+ ].join(" ")}
224
+ aria-label="세션 메뉴"
225
+ >
226
+ <svg viewBox="0 0 16 16" fill="currentColor" className="h-3 w-3">
227
+ <circle cx="3" cy="8" r="1.2" />
228
+ <circle cx="8" cy="8" r="1.2" />
229
+ <circle cx="13" cy="8" r="1.2" />
230
+ </svg>
231
+ </button>
232
+
233
+ {isMenuOpen && (
234
+ <div
235
+ className={[
236
+ "absolute right-0 top-full z-20 mt-1 min-w-[120px]",
237
+ "rounded-lg border border-gray-900/[0.08] bg-white py-1",
238
+ "shadow-[0_4px_16px_rgba(0,0,0,0.10)]",
239
+ "dark:border-white/[0.08] dark:bg-[#0e1117]",
240
+ "dark:shadow-[0_4px_16px_rgba(0,0,0,0.5)]",
241
+ ].join(" ")}
242
+ onMouseDown={(e) => e.stopPropagation()}
243
+ >
244
+ <button
245
+ type="button"
246
+ onClick={() => onStartRename(session.info.id, session.info.title)}
247
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-gray-900/70 transition-colors hover:bg-gray-900/[0.05] hover:text-gray-900/90 dark:text-white/70 dark:hover:bg-white/[0.05] dark:hover:text-white/90"
248
+ >
249
+ <svg viewBox="0 0 16 16" fill="currentColor" className="h-3 w-3 shrink-0">
250
+ <path d="M11.013 1.427a1.75 1.75 0 012.474 0l1.086 1.086a1.75 1.75 0 010 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 01-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.609zm1.414 1.06a.25.25 0 00-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 000-.354l-1.086-1.086zM11.189 6.25L9.75 4.81l-6.286 6.287a.25.25 0 00-.064.108l-.558 1.953 1.953-.558a.249.249 0 00.108-.064l6.286-6.286z" />
251
+ </svg>
252
+ 이름 바꾸기
253
+ </button>
254
+ </div>
255
+ )}
256
+ </div>
257
+ )}
258
+ </li>
259
+ );
260
+ })}
261
+ </ul>
262
+ )}
263
+ </nav>
264
+
265
+ <div className="shrink-0 border-t border-gray-900/[0.06] p-2 dark:border-white/[0.06]">
266
+ <button
267
+ type="button"
268
+ onClick={onOpenHarness}
269
+ title="하네스 설정"
270
+ className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-xs text-gray-900/35 transition-colors hover:bg-gray-900/[0.04] hover:text-gray-900/65 dark:text-white/35 dark:hover:bg-white/[0.04] dark:hover:text-white/65"
271
+ >
272
+ <svg viewBox="0 0 16 16" fill="currentColor" className="h-3.5 w-3.5 shrink-0">
273
+ <path fillRule="evenodd" d="M7.429 1.525a6.593 6.593 0 011.142 0c.036.003.108.036.137.146l.289 1.105c.147.56.55.967.997 1.189.174.086.341.183.501.29.417.278.97.423 1.53.27l1.102-.303c.11-.03.175.016.195.046a6.645 6.645 0 01.571.99c.014.03.014.066.006.104a.44.44 0 01-.07.15l-.686.858c-.357.447-.496.975-.446 1.488.016.165.025.332.025.5 0 .168-.009.335-.025.5-.05.513.089 1.04.446 1.488l.687.858c.044.055.072.11.07.15-.008.038-.008.074-.007.103a6.557 6.557 0 01-.57.99c-.02.03-.087.077-.196.047l-1.102-.303c-.56-.153-1.113-.008-1.53.27a6.36 6.36 0 01-.502.29c-.447.222-.85.629-.997 1.189l-.289 1.105c-.029.11-.1.143-.137.146a6.645 6.645 0 01-1.142 0c-.036-.003-.108-.036-.137-.146l-.289-1.105c-.147-.56-.55-.967-.997-1.189a6.36 6.36 0 01-.501-.29c-.417-.278-.97-.423-1.53-.27l-1.102.303c-.11.03-.175-.016-.195-.046a6.557 6.557 0 01-.57-.99c-.014-.03-.014-.066-.007-.104a.44.44 0 01.07-.15l.687-.858c.357-.447.496-.975.446-1.488A6.5 6.5 0 012 8c0-.168.009-.335.025-.5.05-.513-.089-1.04-.446-1.488L.892 5.154a.44.44 0 01-.07-.15c-.008-.038-.008-.074.006-.103.116-.342.27-.67.37-.99.02-.03.087-.077.196-.047l1.102.303c.56.153 1.113.008 1.53-.27.16-.107.327-.204.501-.29.447-.222.85-.629.997-1.189l.289-1.105c.029-.11.1-.143.137-.146zM8 5.5a2.5 2.5 0 100 5 2.5 2.5 0 000-5z" clipRule="evenodd" />
274
+ </svg>
275
+ 하네스 설정
276
+ </button>
277
+ </div>
278
+ </aside>
279
+ );
280
+ }
@@ -0,0 +1,58 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import { AgentSelectModal } from "../AgentSelectModal";
6
+
7
+ const disconnectedAgents = {
8
+ claude: "disconnected",
9
+ gemini: "connected",
10
+ codex: "connecting",
11
+ opencode: "disconnected",
12
+ } as const;
13
+
14
+ describe("AgentSelectModal", () => {
15
+ it("disables agents that are not connected", async () => {
16
+ const user = userEvent.setup();
17
+ const onSelect = vi.fn();
18
+
19
+ render(
20
+ <AgentSelectModal
21
+ open={true}
22
+ onClose={vi.fn()}
23
+ onSelect={onSelect}
24
+ connectionStatusByAgent={disconnectedAgents}
25
+ />,
26
+ );
27
+
28
+ const claude = screen.getByRole("button", { name: /Claude Code/ });
29
+ const codex = screen.getByRole("button", { name: /Codex CLI/ });
30
+ expect(claude).toBeDisabled();
31
+ expect(codex).toBeDisabled();
32
+ expect(screen.getByText("연결 끊김")).toBeInTheDocument();
33
+ expect(screen.getByText("연결 중")).toBeInTheDocument();
34
+
35
+ await user.click(claude);
36
+ expect(onSelect).not.toHaveBeenCalled();
37
+ });
38
+
39
+ it("selects and closes when a connected agent is clicked", async () => {
40
+ const user = userEvent.setup();
41
+ const onClose = vi.fn();
42
+ const onSelect = vi.fn();
43
+
44
+ render(
45
+ <AgentSelectModal
46
+ open={true}
47
+ onClose={onClose}
48
+ onSelect={onSelect}
49
+ connectionStatusByAgent={disconnectedAgents}
50
+ />,
51
+ );
52
+
53
+ await user.click(screen.getByRole("button", { name: /Gemini CLI/ }));
54
+
55
+ expect(onSelect).toHaveBeenCalledWith("gemini");
56
+ expect(onClose).toHaveBeenCalledTimes(1);
57
+ });
58
+ });
@@ -0,0 +1,134 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import { ChatInput } from "../ChatInput";
6
+
7
+ describe("ChatInput — rendering", () => {
8
+ it("renders textarea and send button", () => {
9
+ render(<ChatInput onSend={vi.fn()} />);
10
+ expect(screen.getByRole("textbox")).toBeInTheDocument();
11
+ expect(screen.getByRole("button", { name: "전송" })).toBeInTheDocument();
12
+ });
13
+
14
+ it("shows placeholder text", () => {
15
+ render(<ChatInput onSend={vi.fn()} placeholder="Write here" />);
16
+ expect(screen.getByPlaceholderText("Write here")).toBeInTheDocument();
17
+ });
18
+
19
+ it("send button is disabled when input is empty", () => {
20
+ render(<ChatInput onSend={vi.fn()} />);
21
+ expect(screen.getByRole("button", { name: "전송" })).toBeDisabled();
22
+ });
23
+
24
+ it("disables textarea when disabled=true", () => {
25
+ render(<ChatInput onSend={vi.fn()} disabled />);
26
+ expect(screen.getByRole("textbox")).toBeDisabled();
27
+ });
28
+ });
29
+
30
+ describe("ChatInput — sending", () => {
31
+ it("calls onSend when Enter is pressed", async () => {
32
+ const user = userEvent.setup();
33
+ const onSend = vi.fn();
34
+ render(<ChatInput onSend={onSend} />);
35
+
36
+ await user.type(screen.getByRole("textbox"), "hello{Enter}");
37
+
38
+ expect(onSend).toHaveBeenCalledWith("hello");
39
+ });
40
+
41
+ it("does not call onSend on Shift+Enter (newline)", async () => {
42
+ const user = userEvent.setup();
43
+ const onSend = vi.fn();
44
+ render(<ChatInput onSend={onSend} />);
45
+
46
+ await user.type(screen.getByRole("textbox"), "hello{Shift>}{Enter}{/Shift}");
47
+
48
+ expect(onSend).not.toHaveBeenCalled();
49
+ });
50
+
51
+ it("clears input after sending", async () => {
52
+ const user = userEvent.setup();
53
+ render(<ChatInput onSend={vi.fn()} />);
54
+ const textarea = screen.getByRole("textbox");
55
+
56
+ await user.type(textarea, "hello{Enter}");
57
+
58
+ expect(textarea).toHaveValue("");
59
+ });
60
+
61
+ it("does not call onSend when disabled", async () => {
62
+ const user = userEvent.setup();
63
+ const onSend = vi.fn();
64
+ render(<ChatInput onSend={onSend} disabled />);
65
+
66
+ await user.type(screen.getByRole("textbox"), "hello");
67
+ await user.keyboard("{Enter}");
68
+
69
+ expect(onSend).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it("trims whitespace and sends the trimmed value", async () => {
73
+ const user = userEvent.setup();
74
+ const onSend = vi.fn();
75
+ render(<ChatInput onSend={onSend} />);
76
+
77
+ await user.type(screen.getByRole("textbox"), " hello {Enter}");
78
+
79
+ expect(onSend).toHaveBeenCalledWith("hello");
80
+ });
81
+ });
82
+
83
+ describe("ChatInput — slash menu", () => {
84
+ it("shows slash menu when / is typed", async () => {
85
+ const user = userEvent.setup();
86
+ render(<ChatInput onSend={vi.fn()} />);
87
+
88
+ await user.type(screen.getByRole("textbox"), "/");
89
+
90
+ expect(screen.getByText("/status")).toBeInTheDocument();
91
+ expect(screen.getByText("/help")).toBeInTheDocument();
92
+ });
93
+
94
+ it("filters menu items by query", async () => {
95
+ const user = userEvent.setup();
96
+ render(<ChatInput onSend={vi.fn()} />);
97
+
98
+ await user.type(screen.getByRole("textbox"), "/sta");
99
+
100
+ expect(screen.getByText("/status")).toBeInTheDocument();
101
+ expect(screen.queryByText("/help")).not.toBeInTheDocument();
102
+ });
103
+
104
+ it("hides menu on Escape", async () => {
105
+ const user = userEvent.setup();
106
+ render(<ChatInput onSend={vi.fn()} />);
107
+
108
+ await user.type(screen.getByRole("textbox"), "/");
109
+ expect(screen.getByText("/status")).toBeInTheDocument();
110
+
111
+ await user.keyboard("{Escape}");
112
+ expect(screen.queryByText("/status")).not.toBeInTheDocument();
113
+ });
114
+
115
+ it("selects a command on Tab and appends space", async () => {
116
+ const user = userEvent.setup();
117
+ render(<ChatInput onSend={vi.fn()} />);
118
+
119
+ await user.type(screen.getByRole("textbox"), "/status");
120
+ await user.keyboard("{Tab}");
121
+
122
+ expect(screen.getByRole("textbox")).toHaveValue("/status ");
123
+ });
124
+
125
+ it("hides menu after command is selected", async () => {
126
+ const user = userEvent.setup();
127
+ render(<ChatInput onSend={vi.fn()} />);
128
+
129
+ await user.type(screen.getByRole("textbox"), "/status");
130
+ await user.keyboard("{Tab}");
131
+
132
+ expect(screen.queryByRole("list")).not.toBeInTheDocument();
133
+ });
134
+ });
@@ -0,0 +1,106 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { ChatMessage, StreamingMessage, SystemMessage } from "../ChatMessage";
5
+ import type { ChatMessage as ChatMessageType } from "../../hooks/useClaudeSessions";
6
+
7
+ // react-markdown과 highlight 관련 패키지를 단순화하여 테스트 복잡도 낮춤
8
+ vi.mock("react-markdown", () => ({
9
+ default: ({ children }: { children: string }) => <div data-testid="markdown">{children}</div>,
10
+ }));
11
+ vi.mock("rehype-highlight", () => ({ default: () => {} }));
12
+ vi.mock("remark-gfm", () => ({ default: () => {} }));
13
+
14
+ function msg(overrides: Partial<ChatMessageType>): ChatMessageType {
15
+ return {
16
+ id: "1",
17
+ role: "user",
18
+ content: "hello",
19
+ createdAt: new Date("2024-01-01"),
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ describe("ChatMessage — user", () => {
25
+ it("renders content in blue bubble", () => {
26
+ render(<ChatMessage message={msg({ role: "user", content: "user text" })} />);
27
+ expect(screen.getByText("user text")).toBeInTheDocument();
28
+ expect(screen.getByText("U")).toBeInTheDocument();
29
+ });
30
+
31
+ it("renders user avatar on the right side", () => {
32
+ const { container } = render(<ChatMessage message={msg({ role: "user" })} />);
33
+ expect(container.firstChild).toHaveClass("justify-end");
34
+ });
35
+ });
36
+
37
+ describe("ChatMessage — assistant", () => {
38
+ it("renders content via markdown", () => {
39
+ render(<ChatMessage message={msg({ role: "assistant", content: "ai response" })} />);
40
+ expect(screen.getByTestId("markdown")).toHaveTextContent("ai response");
41
+ });
42
+
43
+ it("renders C avatar on the left side", () => {
44
+ const { container } = render(<ChatMessage message={msg({ role: "assistant" })} />);
45
+ expect(container.firstChild).toHaveClass("justify-start");
46
+ });
47
+
48
+ it("renders cost metadata when meta is provided", () => {
49
+ render(
50
+ <ChatMessage
51
+ message={msg({
52
+ role: "assistant",
53
+ meta: { result: "ok", isError: false, durationMs: 2300, costUsd: 0.0012 },
54
+ })}
55
+ />,
56
+ );
57
+ expect(screen.getByText(/2\.3s/)).toBeInTheDocument();
58
+ expect(screen.getByText(/\$0\.0012/)).toBeInTheDocument();
59
+ });
60
+
61
+ it("renders tool use card when toolUses present", () => {
62
+ render(
63
+ <ChatMessage
64
+ message={msg({
65
+ role: "assistant",
66
+ content: "",
67
+ toolUses: [{ tool: "Bash", input: { command: "git status" } }],
68
+ })}
69
+ />,
70
+ );
71
+ expect(screen.getByText("Bash")).toBeInTheDocument();
72
+ expect(screen.getByText("git status")).toBeInTheDocument();
73
+ });
74
+ });
75
+
76
+ describe("StreamingMessage", () => {
77
+ it("renders content via markdown when content is non-empty", () => {
78
+ render(<StreamingMessage content="partial response..." />);
79
+ expect(screen.getByTestId("markdown")).toHaveTextContent("partial response...");
80
+ });
81
+
82
+ it("shows bouncing dots when content is empty", () => {
83
+ const { container } = render(<StreamingMessage content="" />);
84
+ // 3 animated dots rendered as ● characters
85
+ expect(container.querySelectorAll(".animate-bounce")).toHaveLength(3);
86
+ });
87
+ });
88
+
89
+ describe("SystemMessage", () => {
90
+ it("renders content in monospace panel", () => {
91
+ render(<SystemMessage content="status output here" />);
92
+ expect(screen.getByText("status output here")).toBeInTheDocument();
93
+ });
94
+
95
+ it("shows system label with ⚡ icon", () => {
96
+ render(<SystemMessage content="x" />);
97
+ expect(screen.getByText("system")).toBeInTheDocument();
98
+ expect(screen.getByText("⚡")).toBeInTheDocument();
99
+ });
100
+
101
+ it("preserves whitespace (pre-wrap)", () => {
102
+ const { container } = render(<SystemMessage content="line1\nline2" />);
103
+ const content = container.querySelector(".whitespace-pre-wrap");
104
+ expect(content).toBeInTheDocument();
105
+ });
106
+ });