@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,125 @@
1
+ import { act, renderHook, waitFor } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import * as tasksApi from "../../api/tasks.api";
5
+ import { useTaskNotification } from "../useTaskNotification";
6
+
7
+ const addToast = vi.fn();
8
+
9
+ vi.mock("@/lib/toast", () => ({
10
+ useToast: () => ({ addToast }),
11
+ }));
12
+
13
+ vi.mock("../../api/tasks.api", () => ({
14
+ fetchTasks: vi.fn(),
15
+ }));
16
+
17
+ const socketMock = vi.hoisted(() => {
18
+ const handlers: Record<string, (payload?: unknown) => void> = {};
19
+ const socket = {
20
+ on: vi.fn((event: string, handler: (payload?: unknown) => void) => {
21
+ handlers[event] = handler;
22
+ return socket;
23
+ }),
24
+ emit: vi.fn(),
25
+ disconnect: vi.fn(),
26
+ };
27
+ return {
28
+ handlers,
29
+ socket,
30
+ io: vi.fn(() => socket),
31
+ reset() {
32
+ for (const key of Object.keys(handlers)) delete handlers[key];
33
+ socket.on.mockClear();
34
+ socket.emit.mockClear();
35
+ socket.disconnect.mockClear();
36
+ this.io.mockClear();
37
+ },
38
+ };
39
+ });
40
+
41
+ vi.mock("socket.io-client", () => ({
42
+ io: socketMock.io,
43
+ }));
44
+
45
+ const mockFetchTasks = vi.mocked(tasksApi.fetchTasks);
46
+
47
+ beforeEach(() => {
48
+ mockFetchTasks.mockResolvedValue([
49
+ {
50
+ id: "task-1",
51
+ title: "Initial",
52
+ status: "running",
53
+ workingDir: null,
54
+ requirements: [],
55
+ agents: [],
56
+ createdAt: "2024-01-01",
57
+ updatedAt: "2024-01-01",
58
+ },
59
+ ]);
60
+ });
61
+
62
+ afterEach(() => {
63
+ vi.clearAllMocks();
64
+ socketMock.reset();
65
+ });
66
+
67
+ describe("useTaskNotification", () => {
68
+ it("loads initial task status and subscribes to global task events", async () => {
69
+ renderHook(() => useTaskNotification());
70
+
71
+ await waitFor(() => expect(mockFetchTasks).toHaveBeenCalledTimes(1));
72
+ act(() => { socketMock.handlers.connect?.(); });
73
+
74
+ expect(socketMock.socket.emit).toHaveBeenCalledWith("task:watch-all");
75
+ });
76
+
77
+ it("shows success toast and marks hasNew on completed transition", async () => {
78
+ const { result } = renderHook(() => useTaskNotification());
79
+ await waitFor(() => expect(mockFetchTasks).toHaveBeenCalledTimes(1));
80
+
81
+ act(() => {
82
+ socketMock.handlers["task:status"]?.({ taskId: "task-1", status: "completed" });
83
+ });
84
+
85
+ expect(addToast).toHaveBeenCalledWith({ type: "success", title: "작업 완료", message: "Initial" });
86
+ expect(result.current.hasNew).toBe(true);
87
+
88
+ act(() => { result.current.clearNew(); });
89
+ expect(result.current.hasNew).toBe(false);
90
+ });
91
+
92
+ it("uses event title and emits error/info toasts", async () => {
93
+ renderHook(() => useTaskNotification());
94
+ await waitFor(() => expect(mockFetchTasks).toHaveBeenCalledTimes(1));
95
+
96
+ act(() => {
97
+ socketMock.handlers["task:status"]?.({ taskId: "task-2", status: "error", title: "Broken" });
98
+ socketMock.handlers["task:status"]?.({ taskId: "task-3", status: "stopped", title: "Stopped" });
99
+ });
100
+
101
+ expect(addToast).toHaveBeenCalledWith({ type: "error", title: "작업 오류", message: "Broken" });
102
+ expect(addToast).toHaveBeenCalledWith({ type: "info", title: "작업 중지됨", message: "Stopped" });
103
+ });
104
+
105
+ it("does not notify repeatedly after a task is already finished", async () => {
106
+ renderHook(() => useTaskNotification());
107
+ await waitFor(() => expect(mockFetchTasks).toHaveBeenCalledTimes(1));
108
+
109
+ act(() => {
110
+ socketMock.handlers["task:status"]?.({ taskId: "task-1", status: "completed" });
111
+ socketMock.handlers["task:status"]?.({ taskId: "task-1", status: "error" });
112
+ });
113
+
114
+ expect(addToast).toHaveBeenCalledTimes(1);
115
+ });
116
+
117
+ it("unsubscribes and disconnects on cleanup", () => {
118
+ const { unmount } = renderHook(() => useTaskNotification());
119
+
120
+ unmount();
121
+
122
+ expect(socketMock.socket.emit).toHaveBeenCalledWith("task:unwatch-all");
123
+ expect(socketMock.socket.disconnect).toHaveBeenCalled();
124
+ });
125
+ });
@@ -0,0 +1,51 @@
1
+ import { renderHook, waitFor } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import type { TaskRun } from "../../api/tasks.api";
5
+ import * as tasksApi from "../../api/tasks.api";
6
+ import { useTaskRuns } from "../useTaskRuns";
7
+
8
+ vi.mock("../../api/tasks.api", () => ({
9
+ fetchTaskRuns: vi.fn(),
10
+ }));
11
+
12
+ const mockFetchTaskRuns = vi.mocked(tasksApi.fetchTaskRuns);
13
+
14
+ const run: TaskRun = {
15
+ id: 1,
16
+ version: 1,
17
+ supplementNote: null,
18
+ status: "completed",
19
+ startedAt: "2024-01-01",
20
+ completedAt: "2024-01-01",
21
+ agentRuns: [],
22
+ };
23
+
24
+ afterEach(() => { vi.clearAllMocks(); });
25
+
26
+ describe("useTaskRuns", () => {
27
+ it("clears runs and skips fetch without a task id", () => {
28
+ const { result } = renderHook(() => useTaskRuns(null));
29
+
30
+ expect(result.current.runs).toEqual([]);
31
+ expect(mockFetchTaskRuns).not.toHaveBeenCalled();
32
+ });
33
+
34
+ it("loads runs for a task id", async () => {
35
+ mockFetchTaskRuns.mockResolvedValueOnce([run]);
36
+ const { result } = renderHook(() => useTaskRuns("task-1"));
37
+
38
+ expect(result.current.loading).toBe(true);
39
+ await waitFor(() => expect(result.current.loading).toBe(false));
40
+ expect(mockFetchTaskRuns).toHaveBeenCalledWith("task-1");
41
+ expect(result.current.runs).toEqual([run]);
42
+ });
43
+
44
+ it("stores errors from the API", async () => {
45
+ mockFetchTaskRuns.mockRejectedValueOnce(new Error("history failed"));
46
+ const { result } = renderHook(() => useTaskRuns("task-1"));
47
+
48
+ await waitFor(() => expect(result.current.loading).toBe(false));
49
+ expect(result.current.error).toBe("history failed");
50
+ });
51
+ });
@@ -0,0 +1,52 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+
5
+ import { useCopyToClipboard } from "./useCopyToClipboard";
6
+
7
+ export function extractCopyableCodeFromPatch(patch: string): string {
8
+ const codeLines: string[] = [];
9
+
10
+ for (const line of patch.split("\n")) {
11
+ if (
12
+ line.startsWith("diff --git ") ||
13
+ line.startsWith("index ") ||
14
+ line.startsWith("new file mode ") ||
15
+ line.startsWith("deleted file mode ") ||
16
+ line.startsWith("rename from ") ||
17
+ line.startsWith("rename to ") ||
18
+ line.startsWith("similarity index ") ||
19
+ line.startsWith("--- ") ||
20
+ line.startsWith("+++ ") ||
21
+ line.startsWith("@@") ||
22
+ line.startsWith("\\ No newline")
23
+ ) {
24
+ continue;
25
+ }
26
+
27
+ if (line.startsWith("+")) {
28
+ codeLines.push(line.slice(1));
29
+ continue;
30
+ }
31
+
32
+ if (line.startsWith("-")) {
33
+ continue;
34
+ }
35
+
36
+ if (line.startsWith(" ")) {
37
+ codeLines.push(line.slice(1));
38
+ }
39
+ }
40
+
41
+ return codeLines.length > 0 ? codeLines.join("\n").trimEnd() : patch.trim();
42
+ }
43
+
44
+ export function useChangelogCodeCopy() {
45
+ const { copy, status } = useCopyToClipboard();
46
+
47
+ const copyCode = useCallback(async (patch: string): Promise<boolean> => {
48
+ return copy(extractCopyableCodeFromPatch(patch));
49
+ }, [copy]);
50
+
51
+ return { copyCode, status };
52
+ }
@@ -0,0 +1,47 @@
1
+ "use client";
2
+
3
+ import { useCallback, useState } from "react";
4
+
5
+ type CopyStatus = "idle" | "copied" | "error";
6
+
7
+ function fallbackCopyText(text: string): void {
8
+ const textarea = document.createElement("textarea");
9
+ textarea.value = text;
10
+ textarea.setAttribute("readonly", "");
11
+ textarea.style.position = "fixed";
12
+ textarea.style.opacity = "0";
13
+ document.body.appendChild(textarea);
14
+ textarea.select();
15
+
16
+ try {
17
+ document.execCommand("copy");
18
+ } finally {
19
+ document.body.removeChild(textarea);
20
+ }
21
+ }
22
+
23
+ export function useCopyToClipboard() {
24
+ const [status, setStatus] = useState<CopyStatus>("idle");
25
+
26
+ const copy = useCallback(async (text: string): Promise<boolean> => {
27
+ try {
28
+ if (!text) throw new Error("복사할 내용이 없습니다.");
29
+
30
+ if (globalThis.navigator.clipboard?.writeText) {
31
+ await globalThis.navigator.clipboard.writeText(text);
32
+ } else {
33
+ fallbackCopyText(text);
34
+ }
35
+
36
+ setStatus("copied");
37
+ window.setTimeout(() => setStatus("idle"), 1500);
38
+ return true;
39
+ } catch {
40
+ setStatus("error");
41
+ window.setTimeout(() => setStatus("idle"), 1500);
42
+ return false;
43
+ }
44
+ }, []);
45
+
46
+ return { copy, status };
47
+ }
@@ -0,0 +1,32 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ import type { AgentChangelog } from "../api/changelog.api";
6
+ import { fetchTaskChangelog } from "../api/changelog.api";
7
+
8
+ export function useTaskChangelog(taskId: string | null) {
9
+ const [changelogs, setChangelogs] = useState<AgentChangelog[]>([]);
10
+ const [loading, setLoading] = useState(false);
11
+ const [error, setError] = useState<string | null>(null);
12
+
13
+ useEffect(() => {
14
+ if (!taskId) {
15
+ setChangelogs([]);
16
+ return;
17
+ }
18
+
19
+ const controller = new AbortController();
20
+ setLoading(true);
21
+ setError(null);
22
+
23
+ fetchTaskChangelog(taskId, controller.signal)
24
+ .then((data) => { if (!controller.signal.aborted) setChangelogs(data); })
25
+ .catch((e: Error) => { if (!controller.signal.aborted) setError(e.message); })
26
+ .finally(() => { if (!controller.signal.aborted) setLoading(false); });
27
+
28
+ return () => { controller.abort(); };
29
+ }, [taskId]);
30
+
31
+ return { changelogs, loading, error };
32
+ }
@@ -0,0 +1,137 @@
1
+ "use client";
2
+
3
+ import { useCallback, useState } from "react";
4
+
5
+ import { createTask } from "../api/tasks.api";
6
+ import type { AgentRole, CreateTaskPayload, Task } from "../api/tasks.api";
7
+ import type { AgentId } from "@/features/chat/ui/AgentSelectModal";
8
+
9
+ export interface AgentDraft {
10
+ id: string; // 로컬 임시 key
11
+ agentType: AgentId;
12
+ role: AgentRole;
13
+ customRole: string;
14
+ }
15
+
16
+ export interface RequirementDraft {
17
+ id: string;
18
+ content: string;
19
+ }
20
+
21
+ export interface TaskFormState {
22
+ title: string;
23
+ workingDir: string;
24
+ requirements: RequirementDraft[];
25
+ agents: AgentDraft[];
26
+ }
27
+
28
+ const INITIAL_STATE: TaskFormState = {
29
+ title: "",
30
+ workingDir: "",
31
+ requirements: [],
32
+ agents: [],
33
+ };
34
+
35
+ let draftId = 0;
36
+ const nextDraftId = () => String(++draftId);
37
+
38
+ export function useTaskCreate(onSuccess?: (task: Task) => void) {
39
+ const [form, setForm] = useState<TaskFormState>(INITIAL_STATE);
40
+ const [submitting, setSubmitting] = useState(false);
41
+ const [error, setError] = useState<string | null>(null);
42
+
43
+ // ─── title / workingDir ──────────────────────────────────────────────
44
+
45
+ const setTitle = useCallback((v: string) => setForm((f) => ({ ...f, title: v })), []);
46
+ const setWorkingDir = useCallback((v: string) => setForm((f) => ({ ...f, workingDir: v })), []);
47
+
48
+ // ─── requirements ────────────────────────────────────────────────────
49
+
50
+ const addRequirement = useCallback(() => {
51
+ setForm((f) => ({
52
+ ...f,
53
+ requirements: [...f.requirements, { id: nextDraftId(), content: "" }],
54
+ }));
55
+ }, []);
56
+
57
+ const updateRequirement = useCallback((id: string, content: string) => {
58
+ setForm((f) => ({
59
+ ...f,
60
+ requirements: f.requirements.map((r) => (r.id === id ? { ...r, content } : r)),
61
+ }));
62
+ }, []);
63
+
64
+ const removeRequirement = useCallback((id: string) => {
65
+ setForm((f) => ({
66
+ ...f,
67
+ requirements: f.requirements.filter((r) => r.id !== id),
68
+ }));
69
+ }, []);
70
+
71
+ // ─── agents ──────────────────────────────────────────────────────────
72
+
73
+ const addAgent = useCallback((agentType: AgentId, role: AgentRole = "frontend") => {
74
+ setForm((f) => ({
75
+ ...f,
76
+ agents: [...f.agents, { id: nextDraftId(), agentType, role, customRole: "" }],
77
+ }));
78
+ }, []);
79
+
80
+ const updateAgent = useCallback((id: string, patch: Partial<Omit<AgentDraft, "id">>) => {
81
+ setForm((f) => ({
82
+ ...f,
83
+ agents: f.agents.map((a) => (a.id === id ? { ...a, ...patch } : a)),
84
+ }));
85
+ }, []);
86
+
87
+ const removeAgent = useCallback((id: string) => {
88
+ setForm((f) => ({ ...f, agents: f.agents.filter((a) => a.id !== id) }));
89
+ }, []);
90
+
91
+ // ─── submit ──────────────────────────────────────────────────────────
92
+
93
+ const submit = useCallback(async () => {
94
+ if (!form.title.trim()) { setError("작업 제목을 입력하세요."); return; }
95
+ setError(null);
96
+ setSubmitting(true);
97
+ try {
98
+ const payload: CreateTaskPayload = {
99
+ title: form.title.trim(),
100
+ workingDir: form.workingDir.trim() || undefined,
101
+ requirements: form.requirements
102
+ .filter((r) => r.content.trim())
103
+ .map((r, i) => ({ content: r.content.trim(), orderIndex: i })),
104
+ agents: form.agents.map((a) => ({
105
+ agentType: a.agentType,
106
+ role: a.role,
107
+ customRole: a.role === "other" && a.customRole.trim() ? a.customRole.trim() : undefined,
108
+ })),
109
+ };
110
+ const task = await createTask(payload);
111
+ setForm(INITIAL_STATE);
112
+ onSuccess?.(task);
113
+ } catch (e) {
114
+ setError(e instanceof Error ? e.message : "작업 생성 실패");
115
+ } finally {
116
+ setSubmitting(false);
117
+ }
118
+ }, [form, onSuccess]);
119
+
120
+ const reset = useCallback(() => { setForm(INITIAL_STATE); setError(null); }, []);
121
+
122
+ return {
123
+ form,
124
+ submitting,
125
+ error,
126
+ setTitle,
127
+ setWorkingDir,
128
+ addRequirement,
129
+ updateRequirement,
130
+ removeRequirement,
131
+ addAgent,
132
+ updateAgent,
133
+ removeAgent,
134
+ submit,
135
+ reset,
136
+ };
137
+ }
@@ -0,0 +1,217 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+
5
+ import { fetchTaskRunChangelog } from "../api/changelog.api";
6
+ import type { AgentChangelog } from "../api/changelog.api";
7
+ import { fetchTaskConversations, fetchTaskRuns } from "../api/tasks.api";
8
+ import type { AgentRole, Task, TaskConversation, TaskRun } from "../api/tasks.api";
9
+
10
+ export type DetailStatusFilter = "all" | "error" | "completed";
11
+ export type DetailAgentFilter = "all" | number;
12
+ export type DetailRoleFilter = "all" | AgentRole;
13
+ export type DetailVersionFilter = "all" | number;
14
+
15
+ export interface DetailVersion {
16
+ run: TaskRun;
17
+ changelogs: AgentChangelog[];
18
+ logsByAgent: Record<number, string>;
19
+ additions: number;
20
+ deletions: number;
21
+ fileCount: number;
22
+ }
23
+
24
+ interface RunChangelog {
25
+ runId: number;
26
+ changelogs: AgentChangelog[];
27
+ }
28
+
29
+ function appendLog(target: Record<number, string>, conversation: TaskConversation): void {
30
+ if (conversation.type !== "agent_message" || conversation.agentId == null) return;
31
+ target[conversation.agentId] = `${target[conversation.agentId] ?? ""}${conversation.content}`;
32
+ }
33
+
34
+ export function useTaskDetail(task: Task | null) {
35
+ const [runs, setRuns] = useState<TaskRun[]>([]);
36
+ const [conversations, setConversations] = useState<TaskConversation[]>([]);
37
+ const [runChangelogs, setRunChangelogs] = useState<RunChangelog[]>([]);
38
+ const [loading, setLoading] = useState(false);
39
+ const [error, setError] = useState<string | null>(null);
40
+
41
+ const [agentFilter, setAgentFilter] = useState<DetailAgentFilter>("all");
42
+ const [roleFilter, setRoleFilter] = useState<DetailRoleFilter>("all");
43
+ const [statusFilter, setStatusFilter] = useState<DetailStatusFilter>("all");
44
+ const [versionFilter, setVersionFilter] = useState<DetailVersionFilter>("all");
45
+
46
+ useEffect(() => {
47
+ if (!task) {
48
+ setRuns([]);
49
+ setConversations([]);
50
+ setRunChangelogs([]);
51
+ return;
52
+ }
53
+
54
+ let active = true;
55
+ const controller = new AbortController();
56
+ const taskId = task.id;
57
+
58
+ setLoading(true);
59
+ setError(null);
60
+
61
+ async function load() {
62
+ try {
63
+ const [nextRuns, nextConversations] = await Promise.all([
64
+ fetchTaskRuns(taskId),
65
+ fetchTaskConversations(taskId),
66
+ ]);
67
+ const changelogResults = await Promise.all(
68
+ nextRuns.map(async (run) => ({
69
+ runId: run.id,
70
+ changelogs: await fetchTaskRunChangelog(taskId, run.id, controller.signal).catch(() => []),
71
+ })),
72
+ );
73
+
74
+ if (!active) return;
75
+ setRuns(nextRuns);
76
+ setConversations(nextConversations);
77
+ setRunChangelogs(changelogResults);
78
+ } catch (e) {
79
+ if (!active) return;
80
+ setError(e instanceof Error ? e.message : "상세 정보를 불러오지 못했습니다.");
81
+ } finally {
82
+ if (active) setLoading(false);
83
+ }
84
+ }
85
+
86
+ void load();
87
+
88
+ return () => {
89
+ active = false;
90
+ controller.abort();
91
+ };
92
+ }, [task]);
93
+
94
+ useEffect(() => {
95
+ setAgentFilter("all");
96
+ setRoleFilter("all");
97
+ setStatusFilter("all");
98
+ setVersionFilter("all");
99
+ }, [task?.id]);
100
+
101
+ const versions = useMemo<DetailVersion[]>(() => {
102
+ const changelogByRun = new Map(runChangelogs.map((entry) => [entry.runId, entry.changelogs]));
103
+ const logsByRun = new Map<number, Record<number, string>>();
104
+
105
+ for (const conversation of conversations) {
106
+ if (conversation.runId == null) continue;
107
+ const current = logsByRun.get(conversation.runId) ?? {};
108
+ appendLog(current, conversation);
109
+ logsByRun.set(conversation.runId, current);
110
+ }
111
+
112
+ return runs.map((run) => {
113
+ const changelogs = changelogByRun.get(run.id) ?? [];
114
+ const files = changelogs.flatMap((entry) => entry.files);
115
+ return {
116
+ run,
117
+ changelogs,
118
+ logsByAgent: logsByRun.get(run.id) ?? {},
119
+ additions: files.reduce((sum, file) => sum + file.additions, 0),
120
+ deletions: files.reduce((sum, file) => sum + file.deletions, 0),
121
+ fileCount: files.length,
122
+ };
123
+ });
124
+ }, [conversations, runChangelogs, runs]);
125
+
126
+ const filteredVersions = useMemo(() => {
127
+ if (!task) return [];
128
+
129
+ const agentById = new Map(task.agents.map((agent) => [agent.id, agent]));
130
+
131
+ const agentMatches = (agentId: number) => {
132
+ if (agentFilter !== "all" && agentFilter !== agentId) return false;
133
+ const agent = agentById.get(agentId);
134
+ if (roleFilter !== "all" && agent?.role !== roleFilter) return false;
135
+ return true;
136
+ };
137
+
138
+ const statusMatches = (run: TaskRun, agentId: number) => {
139
+ if (statusFilter === "all") return true;
140
+ return run.agentRuns.some((agentRun) => agentRun.agentId === agentId && agentRun.status === statusFilter);
141
+ };
142
+
143
+ return versions
144
+ .filter((version) => versionFilter === "all" || version.run.id === versionFilter)
145
+ .map((version) => {
146
+ const visibleAgentIds = new Set<number>();
147
+ for (const agentRun of version.run.agentRuns) visibleAgentIds.add(agentRun.agentId);
148
+ for (const agentId of Object.keys(version.logsByAgent)) visibleAgentIds.add(Number(agentId));
149
+ for (const changelog of version.changelogs) visibleAgentIds.add(changelog.agentId);
150
+
151
+ const allowedAgentIds = new Set(
152
+ Array.from(visibleAgentIds).filter((agentId) => agentMatches(agentId) && statusMatches(version.run, agentId)),
153
+ );
154
+
155
+ const changelogs = version.changelogs
156
+ .filter((changelog) => allowedAgentIds.has(changelog.agentId))
157
+ .map((changelog) => ({ ...changelog, files: [...changelog.files] }));
158
+
159
+ const logsByAgent = Object.fromEntries(
160
+ Object.entries(version.logsByAgent).filter(([agentId]) => allowedAgentIds.has(Number(agentId))),
161
+ );
162
+
163
+ const files = changelogs.flatMap((entry) => entry.files);
164
+ return {
165
+ ...version,
166
+ changelogs,
167
+ logsByAgent,
168
+ run: {
169
+ ...version.run,
170
+ agentRuns: version.run.agentRuns.filter((agentRun) => allowedAgentIds.has(agentRun.agentId)),
171
+ },
172
+ additions: files.reduce((sum, file) => sum + file.additions, 0),
173
+ deletions: files.reduce((sum, file) => sum + file.deletions, 0),
174
+ fileCount: files.length,
175
+ };
176
+ })
177
+ .filter((version) => (
178
+ version.run.agentRuns.length > 0 ||
179
+ Object.keys(version.logsByAgent).length > 0 ||
180
+ version.changelogs.length > 0 ||
181
+ (agentFilter === "all" && roleFilter === "all" && statusFilter === "all")
182
+ ));
183
+ }, [agentFilter, roleFilter, statusFilter, task, versionFilter, versions]);
184
+
185
+ const roleOptions = useMemo(() => {
186
+ const roles = new Set<AgentRole>();
187
+ task?.agents.forEach((agent) => roles.add(agent.role));
188
+ return Array.from(roles);
189
+ }, [task]);
190
+
191
+ const totals = useMemo(() => ({
192
+ versions: versions.length,
193
+ files: versions.reduce((sum, version) => sum + version.fileCount, 0),
194
+ additions: versions.reduce((sum, version) => sum + version.additions, 0),
195
+ deletions: versions.reduce((sum, version) => sum + version.deletions, 0),
196
+ errors: runs.reduce((sum, run) => sum + run.agentRuns.filter((agentRun) => agentRun.status === "error").length, 0),
197
+ completed: runs.reduce((sum, run) => sum + run.agentRuns.filter((agentRun) => agentRun.status === "completed").length, 0),
198
+ }), [runs, versions]);
199
+
200
+ return {
201
+ agentFilter,
202
+ error,
203
+ filteredVersions,
204
+ loading,
205
+ roleFilter,
206
+ roleOptions,
207
+ runs,
208
+ setAgentFilter,
209
+ setRoleFilter,
210
+ setStatusFilter,
211
+ setVersionFilter,
212
+ statusFilter,
213
+ totals,
214
+ versionFilter,
215
+ versions,
216
+ };
217
+ }