@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,413 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+
5
+ import type { AgentChangelog, ChangelogFile } from "../api/changelog.api";
6
+ import type { AgentRole, Task, TaskAgent, TaskAgentRun } from "../api/tasks.api";
7
+ import type { DetailStatusFilter, DetailVersion } from "../hooks/useTaskDetail";
8
+ import { useTaskDetail } from "../hooks/useTaskDetail";
9
+ import { AgentRoleBadge } from "./AgentRoleSelect";
10
+
11
+ const STATUS_LABEL: Record<string, string> = {
12
+ pending: "대기",
13
+ running: "실행 중",
14
+ completed: "완료",
15
+ error: "오류",
16
+ stopped: "중지",
17
+ };
18
+
19
+ const ROLE_LABEL: Record<AgentRole, string> = {
20
+ frontend: "Frontend",
21
+ backend: "Backend",
22
+ doc: "Document",
23
+ operation: "Operation",
24
+ other: "Other",
25
+ };
26
+
27
+ const CHANGE_LABEL: Record<string, string> = {
28
+ added: "추가",
29
+ modified: "수정",
30
+ deleted: "삭제",
31
+ renamed: "이동",
32
+ };
33
+
34
+ function SummaryBar({ additions, deletions }: { additions: number; deletions: number }) {
35
+ const total = additions + deletions;
36
+ const ratio = total > 0 ? (additions / total) * 100 : 50;
37
+
38
+ return (
39
+ <div className="flex items-center gap-3">
40
+ <span className="font-mono text-[11px] text-emerald-600 dark:text-emerald-400">+{additions}</span>
41
+ <div className="h-1.5 flex-1 overflow-hidden rounded-full bg-gray-900/[0.06] dark:bg-white/[0.06]">
42
+ <div className="h-full rounded-full bg-emerald-500" style={{ width: `${ratio}%` }} />
43
+ </div>
44
+ <span className="font-mono text-[11px] text-red-600 dark:text-red-400">-{deletions}</span>
45
+ </div>
46
+ );
47
+ }
48
+
49
+ interface FilterBarProps {
50
+ agents: TaskAgent[];
51
+ roleOptions: AgentRole[];
52
+ runs: DetailVersion["run"][];
53
+ agentFilter: "all" | number;
54
+ roleFilter: "all" | AgentRole;
55
+ statusFilter: DetailStatusFilter;
56
+ versionFilter: "all" | number;
57
+ onAgentChange: (value: "all" | number) => void;
58
+ onRoleChange: (value: "all" | AgentRole) => void;
59
+ onStatusChange: (value: DetailStatusFilter) => void;
60
+ onVersionChange: (value: "all" | number) => void;
61
+ }
62
+
63
+ function FilterBar({
64
+ agents,
65
+ roleOptions,
66
+ runs,
67
+ agentFilter,
68
+ roleFilter,
69
+ statusFilter,
70
+ versionFilter,
71
+ onAgentChange,
72
+ onRoleChange,
73
+ onStatusChange,
74
+ onVersionChange,
75
+ }: FilterBarProps) {
76
+ const statusFilters: { value: DetailStatusFilter; label: string }[] = [
77
+ { value: "all", label: "전체" },
78
+ { value: "error", label: "오류" },
79
+ { value: "completed", label: "완료" },
80
+ ];
81
+
82
+ return (
83
+ <div className="flex flex-col gap-2 rounded-xl border border-gray-900/[0.07] bg-white/60 p-3 dark:border-white/[0.07] dark:bg-white/[0.03]">
84
+ <div className="grid gap-2 md:grid-cols-3">
85
+ <label className="flex flex-col gap-1">
86
+ <span className="text-[10px] font-medium text-gray-900/35 dark:text-white/35">버전</span>
87
+ <select
88
+ value={versionFilter}
89
+ onChange={(event) => onVersionChange(event.target.value === "all" ? "all" : Number(event.target.value))}
90
+ className="rounded-lg border border-gray-900/[0.08] bg-white px-2.5 py-1.5 text-xs text-gray-900/70 outline-none dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white/70"
91
+ >
92
+ <option value="all">전체 버전</option>
93
+ {runs.map((run) => (
94
+ <option key={run.id} value={run.id}>v{run.version}</option>
95
+ ))}
96
+ </select>
97
+ </label>
98
+
99
+ <label className="flex flex-col gap-1">
100
+ <span className="text-[10px] font-medium text-gray-900/35 dark:text-white/35">에이전트</span>
101
+ <select
102
+ value={agentFilter}
103
+ onChange={(event) => onAgentChange(event.target.value === "all" ? "all" : Number(event.target.value))}
104
+ className="rounded-lg border border-gray-900/[0.08] bg-white px-2.5 py-1.5 text-xs text-gray-900/70 outline-none dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white/70"
105
+ >
106
+ <option value="all">전체 에이전트</option>
107
+ {agents.map((agent) => (
108
+ <option key={agent.id} value={agent.id}>
109
+ {agent.customRole ?? ROLE_LABEL[agent.role]} #{agent.id}
110
+ </option>
111
+ ))}
112
+ </select>
113
+ </label>
114
+
115
+ <label className="flex flex-col gap-1">
116
+ <span className="text-[10px] font-medium text-gray-900/35 dark:text-white/35">역할</span>
117
+ <select
118
+ value={roleFilter}
119
+ onChange={(event) => onRoleChange(event.target.value as "all" | AgentRole)}
120
+ className="rounded-lg border border-gray-900/[0.08] bg-white px-2.5 py-1.5 text-xs text-gray-900/70 outline-none dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white/70"
121
+ >
122
+ <option value="all">전체 역할</option>
123
+ {roleOptions.map((role) => (
124
+ <option key={role} value={role}>{ROLE_LABEL[role]}</option>
125
+ ))}
126
+ </select>
127
+ </label>
128
+ </div>
129
+
130
+ <div className="flex w-fit gap-1 rounded-lg border border-gray-900/[0.06] bg-gray-900/[0.02] p-0.5 dark:border-white/[0.06] dark:bg-white/[0.02]">
131
+ {statusFilters.map((filter) => (
132
+ <button
133
+ key={filter.value}
134
+ type="button"
135
+ onClick={() => onStatusChange(filter.value)}
136
+ className={[
137
+ "rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors",
138
+ statusFilter === filter.value
139
+ ? "bg-white text-gray-900/70 shadow-sm dark:bg-white/[0.08] dark:text-white/70"
140
+ : "text-gray-900/35 hover:text-gray-900/60 dark:text-white/35 dark:hover:text-white/60",
141
+ ].join(" ")}
142
+ >
143
+ {filter.label}
144
+ </button>
145
+ ))}
146
+ </div>
147
+ </div>
148
+ );
149
+ }
150
+
151
+ function AgentRunBadge({ agentRun, agent }: { agentRun: TaskAgentRun; agent: TaskAgent | undefined }) {
152
+ const isError = agentRun.status === "error";
153
+ const isCompleted = agentRun.status === "completed";
154
+ return (
155
+ <span className={[
156
+ "inline-flex items-center gap-1 rounded-lg border px-2 py-1 text-[10px] font-medium",
157
+ isError
158
+ ? "border-red-500/20 bg-red-500/[0.06] text-red-600 dark:text-red-400"
159
+ : isCompleted
160
+ ? "border-emerald-500/20 bg-emerald-500/[0.06] text-emerald-600 dark:text-emerald-400"
161
+ : "border-gray-900/[0.08] bg-gray-900/[0.03] text-gray-900/40 dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-white/40",
162
+ ].join(" ")}>
163
+ {agent ? <AgentRoleBadge role={agent.role} customRole={agent.customRole} /> : `Agent ${agentRun.agentId}`}
164
+ <span className="opacity-70">{STATUS_LABEL[agentRun.status] ?? agentRun.status}</span>
165
+ </span>
166
+ );
167
+ }
168
+
169
+ function PatchPreview({ patch }: { patch: string }) {
170
+ const lines = patch.split("\n");
171
+ const hunkStart = lines.findIndex((line) => line.startsWith("@@"));
172
+ const visible = (hunkStart >= 0 ? lines.slice(hunkStart) : lines).slice(0, 80);
173
+
174
+ return (
175
+ <pre className="overflow-x-auto rounded-lg bg-gray-950/[0.03] p-3 text-[11px] leading-relaxed dark:bg-white/[0.03]">
176
+ {visible.map((line, index) => {
177
+ const className = line.startsWith("+")
178
+ ? "bg-emerald-500/[0.08] text-emerald-700 dark:text-emerald-400"
179
+ : line.startsWith("-")
180
+ ? "bg-red-500/[0.08] text-red-700 dark:text-red-400"
181
+ : line.startsWith("@@")
182
+ ? "text-purple-500/70 dark:text-purple-400/70"
183
+ : "text-gray-900/50 dark:text-white/40";
184
+ return <div key={`${line}-${index}`} className={className}>{line}</div>;
185
+ })}
186
+ </pre>
187
+ );
188
+ }
189
+
190
+ function FileChangeRow({ file }: { file: ChangelogFile }) {
191
+ const [open, setOpen] = useState(false);
192
+ const name = file.filePath.split(/[\\/]/).pop() ?? file.filePath;
193
+ const dir = file.filePath.includes("/") ? file.filePath.slice(0, file.filePath.lastIndexOf("/")) : "";
194
+
195
+ return (
196
+ <div className="overflow-hidden rounded-lg border border-gray-900/[0.07] dark:border-white/[0.07]">
197
+ <button
198
+ type="button"
199
+ onClick={() => setOpen((value) => !value)}
200
+ className="flex w-full items-center gap-2 px-3 py-2 text-left"
201
+ >
202
+ <svg viewBox="0 0 16 16" fill="currentColor" className={`h-3 w-3 shrink-0 text-gray-900/20 transition-transform dark:text-white/20 ${open ? "rotate-90" : ""}`}>
203
+ <path fillRule="evenodd" d="M6.22 4.22a.75.75 0 011.06 0l3.25 3.25a.75.75 0 010 1.06l-3.25 3.25a.75.75 0 01-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 010-1.06z" clipRule="evenodd" />
204
+ </svg>
205
+ <span className="rounded-md border border-gray-900/[0.08] px-1.5 py-0.5 text-[10px] text-gray-900/45 dark:border-white/[0.08] dark:text-white/45">
206
+ {CHANGE_LABEL[file.changeType] ?? file.changeType}
207
+ </span>
208
+ <span className="min-w-0 flex-1 truncate text-xs">
209
+ {dir && <span className="text-gray-900/30 dark:text-white/30">{dir}/</span>}
210
+ <span className="font-medium text-gray-900/70 dark:text-white/70">{name}</span>
211
+ </span>
212
+ <span className="shrink-0 font-mono text-[10px]">
213
+ {file.additions > 0 && <span className="text-emerald-600 dark:text-emerald-400">+{file.additions}</span>}
214
+ {file.additions > 0 && file.deletions > 0 && <span className="mx-0.5 text-gray-900/15 dark:text-white/15">/</span>}
215
+ {file.deletions > 0 && <span className="text-red-600 dark:text-red-400">-{file.deletions}</span>}
216
+ </span>
217
+ </button>
218
+ {open && file.patch && (
219
+ <div className="border-t border-gray-900/[0.05] dark:border-white/[0.05]">
220
+ <PatchPreview patch={file.patch} />
221
+ </div>
222
+ )}
223
+ </div>
224
+ );
225
+ }
226
+
227
+ function ChangeSection({ changelogs }: { changelogs: AgentChangelog[] }) {
228
+ const files = changelogs.flatMap((entry) => entry.files);
229
+
230
+ if (files.length === 0) {
231
+ return <p className="rounded-lg border border-gray-900/[0.06] py-5 text-center text-xs text-gray-900/30 dark:border-white/[0.06] dark:text-white/30">변경 사항이 없습니다.</p>;
232
+ }
233
+
234
+ return (
235
+ <div className="flex flex-col gap-1.5">
236
+ {files.map((file) => <FileChangeRow key={file.id} file={file} />)}
237
+ </div>
238
+ );
239
+ }
240
+
241
+ function LogSection({ logsByAgent, agents }: { logsByAgent: Record<number, string>; agents: TaskAgent[] }) {
242
+ const entries = Object.entries(logsByAgent);
243
+
244
+ if (entries.length === 0) {
245
+ return <p className="rounded-lg border border-gray-900/[0.06] py-5 text-center text-xs text-gray-900/30 dark:border-white/[0.06] dark:text-white/30">실행 로그가 없습니다.</p>;
246
+ }
247
+
248
+ return (
249
+ <div className="flex flex-col gap-2">
250
+ {entries.map(([agentId, output]) => {
251
+ const agent = agents.find((entry) => entry.id === Number(agentId));
252
+ return (
253
+ <div key={agentId} className="overflow-hidden rounded-lg border border-gray-900/[0.07] dark:border-white/[0.07]">
254
+ <div className="flex items-center justify-between border-b border-gray-900/[0.05] px-3 py-2 dark:border-white/[0.05]">
255
+ {agent ? <AgentRoleBadge role={agent.role} customRole={agent.customRole} /> : <span className="text-xs text-gray-900/50 dark:text-white/50">Agent {agentId}</span>}
256
+ </div>
257
+ <pre className="max-h-64 overflow-y-auto px-3 py-2 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words text-gray-700 dark:text-gray-300">
258
+ {output}
259
+ </pre>
260
+ </div>
261
+ );
262
+ })}
263
+ </div>
264
+ );
265
+ }
266
+
267
+ function VersionCard({ version, agents, latest }: { version: DetailVersion; agents: TaskAgent[]; latest: boolean }) {
268
+ const [open, setOpen] = useState(latest);
269
+ const duration = version.run.completedAt
270
+ ? Math.round((new Date(version.run.completedAt).getTime() - new Date(version.run.startedAt).getTime()) / 1000)
271
+ : null;
272
+
273
+ return (
274
+ <article className={[
275
+ "overflow-hidden rounded-xl border transition-colors",
276
+ latest
277
+ ? "border-blue-500/20 bg-blue-500/[0.04]"
278
+ : "border-gray-900/[0.07] bg-white/50 dark:border-white/[0.07] dark:bg-white/[0.02]",
279
+ ].join(" ")}>
280
+ <button type="button" onClick={() => setOpen((value) => !value)} className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left">
281
+ <div className="flex min-w-0 items-center gap-2">
282
+ <svg viewBox="0 0 16 16" fill="currentColor" className={`h-3 w-3 shrink-0 text-gray-900/20 transition-transform dark:text-white/20 ${open ? "rotate-90" : ""}`}>
283
+ <path fillRule="evenodd" d="M6.22 4.22a.75.75 0 011.06 0l3.25 3.25a.75.75 0 010 1.06l-3.25 3.25a.75.75 0 01-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 010-1.06z" clipRule="evenodd" />
284
+ </svg>
285
+ <span className="rounded-full bg-blue-500/10 px-2 py-0.5 text-[10px] font-semibold text-blue-600 dark:text-blue-400">v{version.run.version}</span>
286
+ {latest && <span className="text-[10px] text-blue-500/70 dark:text-blue-400/70">최신</span>}
287
+ <span className="truncate text-xs text-gray-900/45 dark:text-white/45">
288
+ {new Date(version.run.startedAt).toLocaleString("ko-KR", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}
289
+ </span>
290
+ </div>
291
+ <div className="flex shrink-0 items-center gap-2 text-[10px] text-gray-900/30 dark:text-white/30">
292
+ {duration != null && <span>{duration}s</span>}
293
+ <span>{version.fileCount} 파일</span>
294
+ <span className="font-mono text-emerald-600 dark:text-emerald-400">+{version.additions}</span>
295
+ <span className="font-mono text-red-600 dark:text-red-400">-{version.deletions}</span>
296
+ </div>
297
+ </button>
298
+
299
+ {open && (
300
+ <div className="flex flex-col gap-4 border-t border-gray-900/[0.05] px-4 py-4 dark:border-white/[0.05]">
301
+ <div className="flex flex-wrap gap-1.5">
302
+ {version.run.agentRuns.map((agentRun) => (
303
+ <AgentRunBadge key={agentRun.id} agentRun={agentRun} agent={agents.find((agent) => agent.id === agentRun.agentId)} />
304
+ ))}
305
+ </div>
306
+
307
+ {version.run.supplementNote && (
308
+ <div className="rounded-lg border border-amber-500/20 bg-amber-500/[0.05] px-3 py-2 text-xs text-amber-700/80 dark:text-amber-400/80">
309
+ <span className="mr-1 font-medium">보완 메시지:</span>
310
+ {version.run.supplementNote}
311
+ </div>
312
+ )}
313
+
314
+ <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
315
+ <section className="flex min-w-0 flex-col gap-2">
316
+ <h3 className="text-xs font-semibold text-gray-900/55 dark:text-white/55">실행 로그</h3>
317
+ <LogSection logsByAgent={version.logsByAgent} agents={agents} />
318
+ </section>
319
+
320
+ <section className="flex min-w-0 flex-col gap-2">
321
+ <h3 className="text-xs font-semibold text-gray-900/55 dark:text-white/55">변경 사항</h3>
322
+ <ChangeSection changelogs={version.changelogs} />
323
+ </section>
324
+ </div>
325
+ </div>
326
+ )}
327
+ </article>
328
+ );
329
+ }
330
+
331
+ interface Props {
332
+ task: Task;
333
+ }
334
+
335
+ export function TaskDetailView({ task }: Props) {
336
+ const detail = useTaskDetail(task);
337
+
338
+ return (
339
+ <div className="mx-auto flex max-w-4xl flex-col gap-4">
340
+ <div className="flex items-start justify-between gap-3">
341
+ <div className="min-w-0">
342
+ <h1 className="truncate text-sm font-semibold text-gray-900/80 dark:text-white/80">{task.title}</h1>
343
+ <p className="mt-1 font-mono text-[11px] text-gray-900/30 dark:text-white/30">{task.id}</p>
344
+ </div>
345
+ <div className="flex shrink-0 items-center gap-1.5 rounded-lg border border-gray-900/[0.07] bg-gray-900/[0.03] px-2.5 py-1.5 dark:border-white/[0.07] dark:bg-white/[0.03]">
346
+ <span className="text-[11px] text-gray-900/40 dark:text-white/40">{detail.totals.versions} 버전</span>
347
+ <span className="text-gray-900/10 dark:text-white/10">/</span>
348
+ <span className="text-[11px] text-gray-900/40 dark:text-white/40">{detail.totals.files} 파일</span>
349
+ </div>
350
+ </div>
351
+
352
+ <div className="rounded-xl border border-gray-900/[0.07] bg-white/60 p-4 dark:border-white/[0.07] dark:bg-white/[0.03]">
353
+ <div className="mb-2 flex items-center justify-between">
354
+ <p className="text-[11px] font-medium text-gray-900/40 dark:text-white/40">변경 통계</p>
355
+ <p className="text-[10px] text-gray-900/30 dark:text-white/30">
356
+ 완료 {detail.totals.completed} / 오류 {detail.totals.errors}
357
+ </p>
358
+ </div>
359
+ <SummaryBar additions={detail.totals.additions} deletions={detail.totals.deletions} />
360
+ </div>
361
+
362
+ <FilterBar
363
+ agents={task.agents}
364
+ roleOptions={detail.roleOptions}
365
+ runs={detail.runs}
366
+ agentFilter={detail.agentFilter}
367
+ roleFilter={detail.roleFilter}
368
+ statusFilter={detail.statusFilter}
369
+ versionFilter={detail.versionFilter}
370
+ onAgentChange={detail.setAgentFilter}
371
+ onRoleChange={detail.setRoleFilter}
372
+ onStatusChange={detail.setStatusFilter}
373
+ onVersionChange={detail.setVersionFilter}
374
+ />
375
+
376
+ {detail.loading && (
377
+ <div className="flex flex-col gap-2">
378
+ {[0, 1, 2].map((index) => (
379
+ <div key={index} className="h-24 animate-pulse rounded-xl border border-gray-900/[0.05] bg-white/50 dark:border-white/[0.05] dark:bg-white/[0.03]" />
380
+ ))}
381
+ </div>
382
+ )}
383
+
384
+ {detail.error && (
385
+ <p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2.5 text-xs text-red-600 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-400">
386
+ {detail.error}
387
+ </p>
388
+ )}
389
+
390
+ {!detail.loading && !detail.error && detail.filteredVersions.length === 0 && (
391
+ <div className="flex flex-col items-center gap-2 rounded-xl border border-gray-900/[0.06] py-12 text-center dark:border-white/[0.06]">
392
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-8 w-8 text-gray-900/15 dark:text-white/15">
393
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
394
+ </svg>
395
+ <p className="text-xs text-gray-900/30 dark:text-white/30">필터 조건에 맞는 실행 기록이 없습니다.</p>
396
+ </div>
397
+ )}
398
+
399
+ {!detail.loading && !detail.error && detail.filteredVersions.length > 0 && (
400
+ <div className="flex flex-col gap-2">
401
+ {detail.filteredVersions.map((version, index) => (
402
+ <VersionCard
403
+ key={version.run.id}
404
+ version={version}
405
+ agents={task.agents}
406
+ latest={detail.versionFilter === "all" ? index === 0 : version.run.id === detail.versions[0]?.run.id}
407
+ />
408
+ ))}
409
+ </div>
410
+ )}
411
+ </div>
412
+ );
413
+ }
@@ -0,0 +1,165 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+
5
+ import { Modal } from "@/components/ui/Modal";
6
+ import { WorkingDirPicker } from "@/components/ui/WorkingDirPicker";
7
+ import { AgentSelectModal } from "@/features/chat/ui/AgentSelectModal";
8
+ import type { AgentId } from "@/features/chat/ui/AgentSelectModal";
9
+ import type { Task } from "../api/tasks.api";
10
+ import { useTaskEdit } from "../hooks/useTaskEdit";
11
+ import { AgentRow } from "./AgentRoleSelect";
12
+
13
+ interface Props {
14
+ task: Task;
15
+ onClose: () => void;
16
+ onSaved: (updated: Task) => void;
17
+ }
18
+
19
+ export function TaskEditModal({ task, onClose, onSaved }: Props) {
20
+ const {
21
+ form, submitting, error,
22
+ setTitle, setWorkingDir,
23
+ addRequirement, updateRequirement, removeRequirement,
24
+ addAgent, updateAgent, removeAgent,
25
+ submit,
26
+ } = useTaskEdit(task, (updated) => { onSaved(updated); onClose(); });
27
+
28
+ const [agentSelectOpen, setAgentSelectOpen] = useState(false);
29
+ const handleAgentSelect = (agentId: AgentId) => { addAgent(agentId); setAgentSelectOpen(false); };
30
+
31
+ return (
32
+ <>
33
+ <AgentSelectModal
34
+ open={agentSelectOpen}
35
+ onClose={() => setAgentSelectOpen(false)}
36
+ onSelect={handleAgentSelect}
37
+ />
38
+ <Modal open onClose={onClose} title="작업 수정" maxWidth="max-w-xl">
39
+ <div className="flex flex-col gap-6">
40
+
41
+ {/* 작업 목표 */}
42
+ <section className="flex flex-col gap-2">
43
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">작업 목표 *</label>
44
+ <textarea
45
+ rows={2}
46
+ value={form.title}
47
+ onChange={(e) => setTitle(e.target.value)}
48
+ placeholder="예: 로그인 페이지 UI 구현 및 API 연동"
49
+ className="w-full resize-none rounded-xl border border-gray-300 bg-gray-50 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 outline-none focus:border-gray-400 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100 dark:placeholder-gray-600 dark:focus:border-gray-500"
50
+ />
51
+ </section>
52
+
53
+ {/* 워크 디렉토리 */}
54
+ <section className="flex flex-col gap-2">
55
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">
56
+ 워크 디렉토리 <span className="text-gray-400 dark:text-gray-600">(선택)</span>
57
+ </label>
58
+ <WorkingDirPicker value={form.workingDir} onChange={setWorkingDir} />
59
+ </section>
60
+
61
+ {/* 요구사항 */}
62
+ <section className="flex flex-col gap-2">
63
+ <div className="flex items-center justify-between">
64
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">요구사항</label>
65
+ <button
66
+ type="button"
67
+ onClick={addRequirement}
68
+ className="flex items-center gap-1 text-xs text-gray-400 transition-colors hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300"
69
+ >
70
+ <span className="text-base leading-none">+</span> 항목 추가
71
+ </button>
72
+ </div>
73
+ {form.requirements.length === 0 ? (
74
+ <p className="rounded-xl border border-dashed border-gray-200 py-4 text-center text-xs text-gray-400 dark:border-gray-800 dark:text-gray-600">
75
+ 요구사항을 추가하세요
76
+ </p>
77
+ ) : (
78
+ <ul className="flex flex-col gap-2">
79
+ {form.requirements.map((req, idx) => (
80
+ <li key={req.id} className="flex items-center gap-2">
81
+ <span className="w-5 shrink-0 text-center text-xs text-gray-400 dark:text-gray-600">{idx + 1}</span>
82
+ <input
83
+ type="text"
84
+ value={req.content}
85
+ onChange={(e) => updateRequirement(req.id, e.target.value)}
86
+ placeholder="요구사항 입력"
87
+ className="flex-1 rounded-lg border border-gray-300 bg-gray-50 px-3 py-1.5 text-xs text-gray-700 placeholder-gray-400 outline-none focus:border-gray-400 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-200 dark:placeholder-gray-600 dark:focus:border-gray-500"
88
+ />
89
+ <button
90
+ type="button"
91
+ onClick={() => removeRequirement(req.id)}
92
+ className="shrink-0 text-gray-300 transition-colors hover:text-red-500 dark:text-gray-600 dark:hover:text-red-400"
93
+ >
94
+ <svg viewBox="0 0 16 16" fill="currentColor" className="h-3.5 w-3.5">
95
+ <path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z" />
96
+ </svg>
97
+ </button>
98
+ </li>
99
+ ))}
100
+ </ul>
101
+ )}
102
+ </section>
103
+
104
+ {/* 서브 에이전트 */}
105
+ <section className="flex flex-col gap-2">
106
+ <div className="flex items-center justify-between">
107
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">서브 에이전트</label>
108
+ <button
109
+ type="button"
110
+ onClick={() => setAgentSelectOpen(true)}
111
+ className="flex items-center gap-1 text-xs text-gray-400 transition-colors hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300"
112
+ >
113
+ <span className="text-base leading-none">+</span> 에이전트 추가
114
+ </button>
115
+ </div>
116
+ {form.agents.length === 0 ? (
117
+ <p className="rounded-xl border border-dashed border-gray-200 py-4 text-center text-xs text-gray-400 dark:border-gray-800 dark:text-gray-600">
118
+ 서브 에이전트를 추가하세요
119
+ </p>
120
+ ) : (
121
+ <ul className="flex flex-col gap-2">
122
+ {form.agents.map((agent) => (
123
+ <li key={agent.id}>
124
+ <AgentRow
125
+ agent={agent}
126
+ onChange={(patch) => updateAgent(agent.id, patch)}
127
+ onRemove={() => removeAgent(agent.id)}
128
+ />
129
+ </li>
130
+ ))}
131
+ </ul>
132
+ )}
133
+ </section>
134
+
135
+ {/* 에러 */}
136
+ {error && (
137
+ <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600 dark:border-red-900 dark:bg-red-950/40 dark:text-red-400">
138
+ {error}
139
+ </p>
140
+ )}
141
+
142
+ {/* 액션 */}
143
+ <div className="flex justify-end gap-2 border-t border-gray-200 pt-4 dark:border-gray-800">
144
+ <button
145
+ type="button"
146
+ onClick={onClose}
147
+ disabled={submitting}
148
+ className="rounded-xl px-4 py-2 text-sm text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
149
+ >
150
+ 취소
151
+ </button>
152
+ <button
153
+ type="button"
154
+ onClick={submit}
155
+ disabled={submitting || !form.title.trim()}
156
+ className="rounded-xl bg-orange-600 px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-orange-500 disabled:cursor-not-allowed disabled:opacity-40"
157
+ >
158
+ {submitting ? "저장 중…" : "저장"}
159
+ </button>
160
+ </div>
161
+ </div>
162
+ </Modal>
163
+ </>
164
+ );
165
+ }