@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.
- package/.gitignore +23 -0
- package/.npmignore +21 -0
- package/.prettierignore +6 -0
- package/.prettierrc +26 -0
- package/AGENTS.md +10 -0
- package/CLAUDE.md +10 -0
- package/README.md +384 -0
- package/apps/server/README.md +294 -0
- package/apps/server/eslint.config.mjs +20 -0
- package/apps/server/nest-cli.json +8 -0
- package/apps/server/package.json +89 -0
- package/apps/server/scripts/postinstall.js +53 -0
- package/apps/server/src/__mocks__/glob.js +6 -0
- package/apps/server/src/__mocks__/uuid.js +5 -0
- package/apps/server/src/app.controller.spec.ts +24 -0
- package/apps/server/src/app.controller.ts +13 -0
- package/apps/server/src/app.module.ts +18 -0
- package/apps/server/src/app.service.ts +8 -0
- package/apps/server/src/common/ji-paths.ts +41 -0
- package/apps/server/src/database/database.module.ts +27 -0
- package/apps/server/src/database/entities/agent-changelog.entity.ts +39 -0
- package/apps/server/src/database/entities/agent-session.entity.ts +29 -0
- package/apps/server/src/database/entities/conversation.entity.ts +41 -0
- package/apps/server/src/database/entities/session.entity.ts +16 -0
- package/apps/server/src/database/entities/task-agent-run.entity.ts +40 -0
- package/apps/server/src/database/entities/task-agent.entity.ts +42 -0
- package/apps/server/src/database/entities/task-requirement.entity.ts +27 -0
- package/apps/server/src/database/entities/task-run.entity.ts +41 -0
- package/apps/server/src/database/entities/task.entity.ts +44 -0
- package/apps/server/src/main.ts +65 -0
- package/apps/server/src/modules/agents/agent-model-settings.spec.ts +80 -0
- package/apps/server/src/modules/agents/agents.module.ts +11 -0
- package/apps/server/src/modules/agents/claude/claude-auth.manager.ts +83 -0
- package/apps/server/src/modules/agents/claude/claude-pty.manager.ts +380 -0
- package/apps/server/src/modules/agents/claude/claude.controller.ts +85 -0
- package/apps/server/src/modules/agents/claude/claude.gateway.ts +158 -0
- package/apps/server/src/modules/agents/claude/claude.module.ts +18 -0
- package/apps/server/src/modules/agents/claude/claude.service.ts +67 -0
- package/apps/server/src/modules/agents/claude/dto/create-session.dto.ts +24 -0
- package/apps/server/src/modules/agents/claude/dto/resize-session.dto.ts +13 -0
- package/apps/server/src/modules/agents/claude/dto/send-input.dto.ts +9 -0
- package/apps/server/src/modules/agents/claude/interfaces/claude-session.interface.ts +26 -0
- package/apps/server/src/modules/agents/claude/interfaces/pty-event.interface.ts +10 -0
- package/apps/server/src/modules/agents/claude/interfaces/stream-event.interface.ts +61 -0
- package/apps/server/src/modules/agents/codex/codex-auth.manager.ts +107 -0
- package/apps/server/src/modules/agents/codex/codex-session.manager.ts +357 -0
- package/apps/server/src/modules/agents/codex/codex.controller.ts +64 -0
- package/apps/server/src/modules/agents/codex/codex.gateway.ts +97 -0
- package/apps/server/src/modules/agents/codex/codex.module.ts +17 -0
- package/apps/server/src/modules/agents/codex/dto/configure-auth.dto.ts +7 -0
- package/apps/server/src/modules/agents/gemini/dto/configure-auth.dto.ts +15 -0
- package/apps/server/src/modules/agents/gemini/dto/create-session.dto.ts +9 -0
- package/apps/server/src/modules/agents/gemini/dto/send-input.dto.ts +9 -0
- package/apps/server/src/modules/agents/gemini/gemini-auth.manager.ts +157 -0
- package/apps/server/src/modules/agents/gemini/gemini-session.manager.ts +287 -0
- package/apps/server/src/modules/agents/gemini/gemini.controller.ts +93 -0
- package/apps/server/src/modules/agents/gemini/gemini.gateway.ts +149 -0
- package/apps/server/src/modules/agents/gemini/gemini.module.ts +17 -0
- package/apps/server/src/modules/agents/gemini/interfaces/gemini-session.interface.ts +18 -0
- package/apps/server/src/modules/agents/gemini/interfaces/stream-event.interface.ts +14 -0
- package/apps/server/src/modules/agents/session-termination.spec.ts +103 -0
- package/apps/server/src/modules/changelog/changelog.controller.ts +20 -0
- package/apps/server/src/modules/changelog/changelog.module.ts +14 -0
- package/apps/server/src/modules/changelog/changelog.service.spec.ts +531 -0
- package/apps/server/src/modules/changelog/changelog.service.ts +690 -0
- package/apps/server/src/modules/conversations/conversation.controller.spec.ts +106 -0
- package/apps/server/src/modules/conversations/conversation.controller.ts +60 -0
- package/apps/server/src/modules/conversations/conversation.module.ts +14 -0
- package/apps/server/src/modules/conversations/conversation.service.spec.ts +176 -0
- package/apps/server/src/modules/conversations/conversation.service.ts +54 -0
- package/apps/server/src/modules/conversations/dto/create-conversation.dto.ts +37 -0
- package/apps/server/src/modules/conversations/enums/conversation.enum.ts +13 -0
- package/apps/server/src/modules/fs/fs.controller.ts +29 -0
- package/apps/server/src/modules/fs/fs.module.ts +8 -0
- package/apps/server/src/modules/harness/dto/save-harness.dto.ts +9 -0
- package/apps/server/src/modules/harness/harness.controller.spec.ts +95 -0
- package/apps/server/src/modules/harness/harness.controller.ts +35 -0
- package/apps/server/src/modules/harness/harness.module.ts +11 -0
- package/apps/server/src/modules/harness/harness.service.spec.ts +217 -0
- package/apps/server/src/modules/harness/harness.service.ts +112 -0
- package/apps/server/src/modules/sessions/session.controller.spec.ts +68 -0
- package/apps/server/src/modules/sessions/session.controller.ts +43 -0
- package/apps/server/src/modules/sessions/session.module.ts +14 -0
- package/apps/server/src/modules/sessions/session.service.spec.ts +106 -0
- package/apps/server/src/modules/sessions/session.service.ts +35 -0
- package/apps/server/src/modules/tasks/dto/create-task.dto.ts +54 -0
- package/apps/server/src/modules/tasks/dto/execute-task.dto.ts +22 -0
- package/apps/server/src/modules/tasks/dto/merge-file.dto.ts +7 -0
- package/apps/server/src/modules/tasks/dto/rerun-task.dto.ts +14 -0
- package/apps/server/src/modules/tasks/dto/update-task.dto.ts +55 -0
- package/apps/server/src/modules/tasks/task-execution.service.ts +978 -0
- package/apps/server/src/modules/tasks/task.gateway.ts +140 -0
- package/apps/server/src/modules/tasks/tasks.controller.spec.ts +210 -0
- package/apps/server/src/modules/tasks/tasks.controller.ts +139 -0
- package/apps/server/src/modules/tasks/tasks.module.ts +30 -0
- package/apps/server/src/modules/tasks/tasks.service.spec.ts +552 -0
- package/apps/server/src/modules/tasks/tasks.service.ts +333 -0
- package/apps/server/test/app.e2e-spec.ts +28 -0
- package/apps/server/test/jest-e2e.json +9 -0
- package/apps/server/tsconfig.build.json +4 -0
- package/apps/server/tsconfig.json +13 -0
- package/apps/web/AGENTS.md +7 -0
- package/apps/web/CLAUDE.md +1 -0
- package/apps/web/README.md +36 -0
- package/apps/web/eslint.config.mjs +21 -0
- package/apps/web/next-env.d.ts +6 -0
- package/apps/web/next.config.ts +7 -0
- package/apps/web/package.json +49 -0
- package/apps/web/postcss.config.mjs +7 -0
- package/apps/web/public/file.svg +1 -0
- package/apps/web/public/globe.svg +1 -0
- package/apps/web/public/next.svg +1 -0
- package/apps/web/public/vercel.svg +1 -0
- package/apps/web/public/window.svg +1 -0
- package/apps/web/src/app/claude/page.tsx +5 -0
- package/apps/web/src/app/codex/page.tsx +126 -0
- package/apps/web/src/app/favicon.ico +0 -0
- package/apps/web/src/app/gemini/page.tsx +130 -0
- package/apps/web/src/app/globals.css +149 -0
- package/apps/web/src/app/layout.tsx +40 -0
- package/apps/web/src/app/login/page.tsx +67 -0
- package/apps/web/src/app/page.tsx +497 -0
- package/apps/web/src/app/task/[id]/page.tsx +11 -0
- package/apps/web/src/app/test/page.tsx +298 -0
- package/apps/web/src/components/ui/Modal.tsx +78 -0
- package/apps/web/src/components/ui/WorkingDirPicker.tsx +195 -0
- package/apps/web/src/components/ui/__tests__/Modal.test.tsx +68 -0
- package/apps/web/src/features/auth/api/__tests__/auth.api.test.ts +83 -0
- package/apps/web/src/features/auth/api/auth.api.ts +81 -0
- package/apps/web/src/features/auth/hooks/__tests__/useClaudeAuth.test.ts +166 -0
- package/apps/web/src/features/auth/hooks/__tests__/useCodexAuth.test.ts +127 -0
- package/apps/web/src/features/auth/hooks/__tests__/useGeminiAuth.test.ts +120 -0
- package/apps/web/src/features/auth/hooks/useClaudeAuth.ts +88 -0
- package/apps/web/src/features/auth/hooks/useCodexAuth.ts +149 -0
- package/apps/web/src/features/auth/hooks/useGeminiAuth.ts +125 -0
- package/apps/web/src/features/auth/ui/CodexLoginPanel.tsx +302 -0
- package/apps/web/src/features/auth/ui/GeminiLoginPanel.tsx +316 -0
- package/apps/web/src/features/auth/ui/LoginForm.tsx +190 -0
- package/apps/web/src/features/auth/ui/LoginPanel.tsx +114 -0
- package/apps/web/src/features/auth/ui/__tests__/LoginPanel.test.tsx +105 -0
- package/apps/web/src/features/chat/api/__tests__/sessions.api.test.ts +187 -0
- package/apps/web/src/features/chat/api/sessions.api.ts +161 -0
- package/apps/web/src/features/chat/container/ClaudePageContainer.tsx +152 -0
- package/apps/web/src/features/chat/hooks/__tests__/useCodexSessions.test.ts +131 -0
- package/apps/web/src/features/chat/hooks/__tests__/useGeminiSessions.test.ts +130 -0
- package/apps/web/src/features/chat/hooks/useAgentModelSettings.ts +54 -0
- package/apps/web/src/features/chat/hooks/useClaudeSessions.ts +323 -0
- package/apps/web/src/features/chat/hooks/useCodexSessions.ts +275 -0
- package/apps/web/src/features/chat/hooks/useGeminiSessions.ts +255 -0
- package/apps/web/src/features/chat/hooks/useSessionCommand.ts +66 -0
- package/apps/web/src/features/chat/hooks/useSessionRename.ts +61 -0
- package/apps/web/src/features/chat/hooks/useSessionWorkingDirectories.ts +34 -0
- package/apps/web/src/features/chat/hooks/useUnifiedSessions.ts +156 -0
- package/apps/web/src/features/chat/lib/agentModelOptions.ts +72 -0
- package/apps/web/src/features/chat/ui/AgentModelPicker.tsx +134 -0
- package/apps/web/src/features/chat/ui/AgentSelectModal.tsx +236 -0
- package/apps/web/src/features/chat/ui/ChatInput.tsx +162 -0
- package/apps/web/src/features/chat/ui/ChatMessage.tsx +204 -0
- package/apps/web/src/features/chat/ui/ChatWorkspace.tsx +207 -0
- package/apps/web/src/features/chat/ui/CheckingSkeleton.tsx +44 -0
- package/apps/web/src/features/chat/ui/ClaudeLoginView.tsx +44 -0
- package/apps/web/src/features/chat/ui/PermissionCard.tsx +37 -0
- package/apps/web/src/features/chat/ui/SessionSidebar.tsx +280 -0
- package/apps/web/src/features/chat/ui/__tests__/AgentSelectModal.test.tsx +58 -0
- package/apps/web/src/features/chat/ui/__tests__/ChatInput.test.tsx +134 -0
- package/apps/web/src/features/chat/ui/__tests__/ChatMessage.test.tsx +106 -0
- package/apps/web/src/features/chat/ui/__tests__/ChatWorkspace.test.tsx +66 -0
- package/apps/web/src/features/diff/ui/DiffFileRow.tsx +73 -0
- package/apps/web/src/features/diff/ui/DiffHunk.tsx +61 -0
- package/apps/web/src/features/diff/ui/FileChangeBadge.tsx +23 -0
- package/apps/web/src/features/diff/ui/__tests__/DiffFileRow.test.tsx +40 -0
- package/apps/web/src/features/diff/ui/__tests__/DiffHunk.test.tsx +24 -0
- package/apps/web/src/features/diff/ui/__tests__/FileChangeBadge.test.tsx +16 -0
- package/apps/web/src/features/fs/api/fs.api.ts +14 -0
- package/apps/web/src/features/fs/hooks/useDirBrowser.ts +50 -0
- package/apps/web/src/features/harness/api/__tests__/harness.api.test.ts +73 -0
- package/apps/web/src/features/harness/api/harness.api.ts +46 -0
- package/apps/web/src/features/harness/hooks/__tests__/useHarness.test.ts +65 -0
- package/apps/web/src/features/harness/hooks/useHarness.ts +66 -0
- package/apps/web/src/features/harness/ui/HarnessModal.tsx +171 -0
- package/apps/web/src/features/harness/ui/__tests__/HarnessModal.test.tsx +46 -0
- package/apps/web/src/features/status/ui/AgentStatusModal.tsx +267 -0
- package/apps/web/src/features/status/ui/__tests__/AgentStatusModal.test.tsx +71 -0
- package/apps/web/src/features/tasks/api/__tests__/changelog.api.test.ts +89 -0
- package/apps/web/src/features/tasks/api/__tests__/tasks.api.test.ts +282 -0
- package/apps/web/src/features/tasks/api/changelog.api.ts +52 -0
- package/apps/web/src/features/tasks/api/tasks.api.ts +175 -0
- package/apps/web/src/features/tasks/container/TaskDetailPageContainer.tsx +69 -0
- package/apps/web/src/features/tasks/hooks/__tests__/useChangelogCodeCopy.test.ts +48 -0
- package/apps/web/src/features/tasks/hooks/__tests__/useTaskChangelog.test.ts +48 -0
- package/apps/web/src/features/tasks/hooks/__tests__/useTaskCreate.test.ts +217 -0
- package/apps/web/src/features/tasks/hooks/__tests__/useTaskEdit.test.ts +152 -0
- package/apps/web/src/features/tasks/hooks/__tests__/useTaskExecution.test.ts +143 -0
- package/apps/web/src/features/tasks/hooks/__tests__/useTaskList.test.ts +168 -0
- package/apps/web/src/features/tasks/hooks/__tests__/useTaskNotification.test.ts +125 -0
- package/apps/web/src/features/tasks/hooks/__tests__/useTaskRuns.test.ts +51 -0
- package/apps/web/src/features/tasks/hooks/useChangelogCodeCopy.ts +52 -0
- package/apps/web/src/features/tasks/hooks/useCopyToClipboard.ts +47 -0
- package/apps/web/src/features/tasks/hooks/useTaskChangelog.ts +32 -0
- package/apps/web/src/features/tasks/hooks/useTaskCreate.ts +137 -0
- package/apps/web/src/features/tasks/hooks/useTaskDetail.ts +217 -0
- package/apps/web/src/features/tasks/hooks/useTaskEdit.ts +130 -0
- package/apps/web/src/features/tasks/hooks/useTaskExecution.ts +137 -0
- package/apps/web/src/features/tasks/hooks/useTaskList.ts +159 -0
- package/apps/web/src/features/tasks/hooks/useTaskNotification.ts +80 -0
- package/apps/web/src/features/tasks/hooks/useTaskRuns.ts +32 -0
- package/apps/web/src/features/tasks/ui/AgentOutputPanel.tsx +203 -0
- package/apps/web/src/features/tasks/ui/AgentRoleSelect.tsx +97 -0
- package/apps/web/src/features/tasks/ui/ChangelogPanel.tsx +321 -0
- package/apps/web/src/features/tasks/ui/RunHistoryPanel.tsx +193 -0
- package/apps/web/src/features/tasks/ui/TaskCreateModal.tsx +205 -0
- package/apps/web/src/features/tasks/ui/TaskDetailView.tsx +413 -0
- package/apps/web/src/features/tasks/ui/TaskEditModal.tsx +165 -0
- package/apps/web/src/features/tasks/ui/TaskListModal.tsx +591 -0
- package/apps/web/src/features/tasks/ui/__tests__/AgentRoleSelect.test.tsx +91 -0
- package/apps/web/src/features/tasks/ui/__tests__/ChangelogPanel.test.tsx +94 -0
- package/apps/web/src/features/tasks/ui/__tests__/RunHistoryPanel.test.tsx +71 -0
- package/apps/web/src/features/tasks/ui/__tests__/TaskCreateModal.test.tsx +153 -0
- package/apps/web/src/features/tasks/ui/__tests__/TaskEditModal.test.tsx +75 -0
- package/apps/web/src/features/tasks/ui/__tests__/TaskListModal.test.tsx +243 -0
- package/apps/web/src/hooks/useWorkingDir.ts +28 -0
- package/apps/web/src/lib/__tests__/ansi.test.ts +88 -0
- package/apps/web/src/lib/ansi.ts +105 -0
- package/apps/web/src/lib/constants.ts +4 -0
- package/apps/web/src/lib/quota.ts +22 -0
- package/apps/web/src/lib/theme.tsx +78 -0
- package/apps/web/src/lib/toast.tsx +175 -0
- package/apps/web/src/store/agentStatusStore.ts +38 -0
- package/apps/web/tsconfig.json +18 -0
- package/apps/web/vitest.config.ts +25 -0
- package/apps/web/vitest.setup.ts +10 -0
- package/package.json +85 -0
- package/packages/cli/dist/commands/check.d.ts +1 -0
- package/packages/cli/dist/commands/check.js +89 -0
- package/packages/cli/dist/commands/init.d.ts +5 -0
- package/packages/cli/dist/commands/init.js +183 -0
- package/packages/cli/dist/commands/start.d.ts +4 -0
- package/packages/cli/dist/commands/start.js +188 -0
- package/packages/cli/dist/index.d.ts +2 -0
- package/packages/cli/dist/index.js +71 -0
- package/packages/cli/dist/utils/agent-tools.d.ts +28 -0
- package/packages/cli/dist/utils/agent-tools.js +193 -0
- package/packages/cli/dist/utils/project-init.d.ts +12 -0
- package/packages/cli/dist/utils/project-init.js +258 -0
- package/packages/cli/dist/utils/proxy.d.ts +8 -0
- package/packages/cli/dist/utils/proxy.js +138 -0
- package/packages/cli/package.json +30 -0
- package/packages/cli/src/commands/check.ts +77 -0
- package/packages/cli/src/commands/init.ts +209 -0
- package/packages/cli/src/commands/start.ts +183 -0
- package/packages/cli/src/index.ts +91 -0
- package/packages/cli/src/utils/agent-tools.ts +201 -0
- package/packages/cli/src/utils/project-init.ts +252 -0
- package/packages/cli/src/utils/proxy.ts +123 -0
- package/packages/cli/tsconfig.json +14 -0
- package/packages/eslint-config/base.mjs +31 -0
- package/packages/eslint-config/nest.mjs +55 -0
- package/packages/eslint-config/next.mjs +23 -0
- package/packages/eslint-config/package.json +20 -0
- package/packages/typescript-config/base.json +16 -0
- package/packages/typescript-config/nestjs.json +17 -0
- package/packages/typescript-config/nextjs.json +15 -0
- package/packages/typescript-config/package.json +11 -0
- package/turbo.json +28 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { Modal } from "@/components/ui/Modal";
|
|
6
|
+
import type { HarnessExt, HarnessRole } from "../api/harness.api";
|
|
7
|
+
import { HARNESS_ROLES } from "../api/harness.api";
|
|
8
|
+
import { useHarness } from "../hooks/useHarness";
|
|
9
|
+
|
|
10
|
+
// ─── 역할 사이드바 ────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const ROLE_ICONS: Record<HarnessRole, React.ReactNode> = {
|
|
13
|
+
common: (
|
|
14
|
+
<svg viewBox="0 0 16 16" fill="currentColor" className="h-3.5 w-3.5">
|
|
15
|
+
<path d="M8 0a8 8 0 100 16A8 8 0 008 0zM4.5 7.5a.5.5 0 000 1h7a.5.5 0 000-1h-7z" />
|
|
16
|
+
</svg>
|
|
17
|
+
),
|
|
18
|
+
frontend: (
|
|
19
|
+
<svg viewBox="0 0 16 16" fill="currentColor" className="h-3.5 w-3.5">
|
|
20
|
+
<path d="M0 2.75A2.75 2.75 0 012.75 0h10.5A2.75 2.75 0 0116 2.75v10.5A2.75 2.75 0 0113.25 16H2.75A2.75 2.75 0 010 13.25V2.75zM2.75 1.5A1.25 1.25 0 001.5 2.75V4h13V2.75A1.25 1.25 0 0013.25 1.5H2.75zM1.5 5.5v7.75c0 .69.56 1.25 1.25 1.25h10.5c.69 0 1.25-.56 1.25-1.25V5.5H1.5z" />
|
|
21
|
+
</svg>
|
|
22
|
+
),
|
|
23
|
+
backend: (
|
|
24
|
+
<svg viewBox="0 0 16 16" fill="currentColor" className="h-3.5 w-3.5">
|
|
25
|
+
<path d="M1 3.5A1.5 1.5 0 012.5 2h11A1.5 1.5 0 0115 3.5v2A1.5 1.5 0 0113.5 7h-11A1.5 1.5 0 011 5.5v-2zm0 6A1.5 1.5 0 012.5 8h11A1.5 1.5 0 0115 9.5v2A1.5 1.5 0 0113.5 13h-11A1.5 1.5 0 011 11.5v-2z" />
|
|
26
|
+
</svg>
|
|
27
|
+
),
|
|
28
|
+
doc: (
|
|
29
|
+
<svg viewBox="0 0 16 16" fill="currentColor" className="h-3.5 w-3.5">
|
|
30
|
+
<path d="M4 1.75C4 .784 4.784 0 5.75 0h5.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0114.25 16h-8.5A1.75 1.75 0 014 14.25V1.75zM5.75 1.5a.25.25 0 00-.25.25v12.5c0 .138.112.25.25.25h8.5a.25.25 0 00.25-.25V6h-2.75A1.75 1.75 0 0110 4.25V1.5H5.75zm4.75.44v2.31c0 .138.112.25.25.25h2.31L10.5 1.94z" />
|
|
31
|
+
</svg>
|
|
32
|
+
),
|
|
33
|
+
operation: (
|
|
34
|
+
<svg viewBox="0 0 16 16" fill="currentColor" className="h-3.5 w-3.5">
|
|
35
|
+
<path d="M8 0a8 8 0 110 16A8 8 0 018 0zM1.5 8a6.5 6.5 0 1013 0 6.5 6.5 0 00-13 0z" />
|
|
36
|
+
</svg>
|
|
37
|
+
),
|
|
38
|
+
other: (
|
|
39
|
+
<svg viewBox="0 0 16 16" fill="currentColor" className="h-3.5 w-3.5">
|
|
40
|
+
<path d="M8 9.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM1.5 9.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM14.5 8a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0z" />
|
|
41
|
+
</svg>
|
|
42
|
+
),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ─── Editor ───────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
interface EditorProps {
|
|
48
|
+
role: HarnessRole;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function HarnessEditor({ role }: EditorProps) {
|
|
52
|
+
const { content, ext, loading, saving, dirty, setContent, setExt, save } = useHarness(role);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="flex flex-1 flex-col gap-3 overflow-hidden">
|
|
56
|
+
{/* 확장자 토글 + 저장 */}
|
|
57
|
+
<div className="flex items-center justify-between">
|
|
58
|
+
<div className="flex rounded-lg border border-gray-900/[0.08] p-0.5 dark:border-white/[0.08]">
|
|
59
|
+
{(["md", "tsx"] as HarnessExt[]).map((e) => (
|
|
60
|
+
<button
|
|
61
|
+
key={e}
|
|
62
|
+
type="button"
|
|
63
|
+
onClick={() => setExt(e)}
|
|
64
|
+
className={[
|
|
65
|
+
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
|
|
66
|
+
ext === e
|
|
67
|
+
? "bg-gray-900/[0.07] text-gray-900/75 dark:bg-white/[0.07] dark:text-white/75"
|
|
68
|
+
: "text-gray-900/30 hover:text-gray-900/55 dark:text-white/30 dark:hover:text-white/55",
|
|
69
|
+
].join(" ")}
|
|
70
|
+
>
|
|
71
|
+
.{e}
|
|
72
|
+
</button>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
onClick={() => void save()}
|
|
79
|
+
disabled={saving || !dirty}
|
|
80
|
+
className="flex items-center gap-1.5 rounded-lg bg-orange-600 px-3.5 py-1.5 text-xs font-medium text-white transition-colors hover:bg-orange-500 disabled:opacity-40"
|
|
81
|
+
>
|
|
82
|
+
{saving && (
|
|
83
|
+
<span className="h-3 w-3 animate-spin rounded-full border border-white/30 border-t-white" />
|
|
84
|
+
)}
|
|
85
|
+
{saving ? "저장 중…" : "저장"}
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{/* 텍스트 에디터 */}
|
|
90
|
+
{loading ? (
|
|
91
|
+
<div className="flex flex-1 items-center justify-center">
|
|
92
|
+
<span className="h-5 w-5 animate-spin rounded-full border-2 border-gray-900/[0.08] border-t-orange-500 dark:border-white/[0.08]" />
|
|
93
|
+
</div>
|
|
94
|
+
) : (
|
|
95
|
+
<div className="relative flex-1 overflow-hidden rounded-xl border border-gray-900/[0.08] dark:border-white/[0.08]">
|
|
96
|
+
<div className="absolute right-3 top-2.5 z-10 font-mono text-[10px] text-gray-900/20 dark:text-white/20">
|
|
97
|
+
.{ext}
|
|
98
|
+
</div>
|
|
99
|
+
<textarea
|
|
100
|
+
value={content}
|
|
101
|
+
onChange={(e) => setContent(e.target.value)}
|
|
102
|
+
spellCheck={false}
|
|
103
|
+
placeholder={
|
|
104
|
+
ext === "md"
|
|
105
|
+
? `# ${role} 하네스\n\n에이전트에게 적용할 지시사항을 작성하세요.`
|
|
106
|
+
: `// ${role} harness\n// 에이전트에게 적용할 지시사항을 작성하세요.`
|
|
107
|
+
}
|
|
108
|
+
className="h-full w-full resize-none bg-gray-900/[0.02] p-4 font-mono text-sm text-gray-900/75 placeholder-gray-900/15 outline-none dark:bg-white/[0.02] dark:text-white/75 dark:placeholder-white/15"
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
{dirty && (
|
|
114
|
+
<p className="text-[10px] text-amber-600/70 dark:text-amber-400/70">
|
|
115
|
+
저장되지 않은 변경사항이 있습니다
|
|
116
|
+
</p>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── HarnessModal ─────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
interface Props {
|
|
125
|
+
open: boolean;
|
|
126
|
+
onClose: () => void;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function HarnessModal({ open, onClose }: Props) {
|
|
130
|
+
const [selectedRole, setSelectedRole] = useState<HarnessRole>("common");
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<Modal open={open} onClose={onClose} title="하네스 설정" maxWidth="max-w-3xl">
|
|
134
|
+
<div className="flex gap-4" style={{ height: "480px" }}>
|
|
135
|
+
{/* 역할 사이드바 */}
|
|
136
|
+
<aside className="flex w-36 shrink-0 flex-col gap-0.5">
|
|
137
|
+
{HARNESS_ROLES.map(({ role, label }) => (
|
|
138
|
+
<button
|
|
139
|
+
key={role}
|
|
140
|
+
type="button"
|
|
141
|
+
onClick={() => setSelectedRole(role)}
|
|
142
|
+
className={[
|
|
143
|
+
"flex items-center gap-2 rounded-lg px-3 py-2 text-left text-xs font-medium transition-colors",
|
|
144
|
+
selectedRole === role
|
|
145
|
+
? "bg-orange-500/[0.10] text-orange-600 dark:text-orange-400"
|
|
146
|
+
: "text-gray-900/40 hover:bg-gray-900/[0.04] hover:text-gray-900/70 dark:text-white/40 dark:hover:bg-white/[0.04] dark:hover:text-white/70",
|
|
147
|
+
].join(" ")}
|
|
148
|
+
>
|
|
149
|
+
<span className={selectedRole === role ? "text-orange-500 dark:text-orange-400" : "text-gray-900/25 dark:text-white/25"}>
|
|
150
|
+
{ROLE_ICONS[role]}
|
|
151
|
+
</span>
|
|
152
|
+
{label}
|
|
153
|
+
</button>
|
|
154
|
+
))}
|
|
155
|
+
|
|
156
|
+
<div className="mt-3 border-t border-gray-900/[0.06] pt-3 dark:border-white/[0.06]">
|
|
157
|
+
<p className="px-3 text-[10px] leading-relaxed text-gray-900/25 dark:text-white/25">
|
|
158
|
+
하네스는 에이전트 실행 시 시스템 프롬프트에 자동 주입됩니다.
|
|
159
|
+
</p>
|
|
160
|
+
</div>
|
|
161
|
+
</aside>
|
|
162
|
+
|
|
163
|
+
{/* 구분선 */}
|
|
164
|
+
<div className="w-px shrink-0 bg-gray-900/[0.06] dark:bg-white/[0.06]" />
|
|
165
|
+
|
|
166
|
+
{/* 에디터 영역 */}
|
|
167
|
+
<HarnessEditor key={selectedRole} role={selectedRole} />
|
|
168
|
+
</div>
|
|
169
|
+
</Modal>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import * as harnessApi from "../../api/harness.api";
|
|
6
|
+
import { HarnessModal } from "../HarnessModal";
|
|
7
|
+
|
|
8
|
+
vi.mock("../../api/harness.api", async () => {
|
|
9
|
+
const actual = await vi.importActual<typeof import("../../api/harness.api")>("../../api/harness.api");
|
|
10
|
+
return {
|
|
11
|
+
...actual,
|
|
12
|
+
fetchHarness: vi.fn(),
|
|
13
|
+
saveHarness: vi.fn(),
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const mockFetchHarness = vi.mocked(harnessApi.fetchHarness);
|
|
18
|
+
const mockSaveHarness = vi.mocked(harnessApi.saveHarness);
|
|
19
|
+
|
|
20
|
+
describe("HarnessModal", () => {
|
|
21
|
+
it("does not render when closed", () => {
|
|
22
|
+
render(<HarnessModal open={false} onClose={vi.fn()} />);
|
|
23
|
+
expect(screen.queryByText("하네스 설정")).not.toBeInTheDocument();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("loads selected role, edits extension/content, and saves", async () => {
|
|
27
|
+
const user = userEvent.setup();
|
|
28
|
+
mockFetchHarness.mockImplementation(async (role) => ({ role, ext: "md", content: `${role} content` }));
|
|
29
|
+
mockSaveHarness.mockResolvedValue({ role: "frontend", ext: "tsx", content: "changed" });
|
|
30
|
+
|
|
31
|
+
render(<HarnessModal open={true} onClose={vi.fn()} />);
|
|
32
|
+
|
|
33
|
+
expect(await screen.findByDisplayValue("common content")).toBeInTheDocument();
|
|
34
|
+
await user.click(screen.getByRole("button", { name: "Frontend" }));
|
|
35
|
+
expect(await screen.findByDisplayValue("frontend content")).toBeInTheDocument();
|
|
36
|
+
|
|
37
|
+
await user.click(screen.getByRole("button", { name: ".tsx" }));
|
|
38
|
+
await user.clear(screen.getByDisplayValue("frontend content"));
|
|
39
|
+
await user.type(screen.getByPlaceholderText(/frontend harness/), "changed");
|
|
40
|
+
|
|
41
|
+
expect(screen.getByText("저장되지 않은 변경사항이 있습니다")).toBeInTheDocument();
|
|
42
|
+
await user.click(screen.getByRole("button", { name: "저장" }));
|
|
43
|
+
|
|
44
|
+
await waitFor(() => expect(mockSaveHarness).toHaveBeenCalledWith("frontend", "changed", "tsx"));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { getClaudeStatus, getGeminiAuthStatus } from "@/features/auth/api/auth.api";
|
|
6
|
+
import type { ClaudeStatus, GeminiAuthStatus } from "@/features/auth/api/auth.api";
|
|
7
|
+
import { Modal } from "@/components/ui/Modal";
|
|
8
|
+
|
|
9
|
+
// ─── Icons ────────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
const ClaudeIcon = () => (
|
|
12
|
+
<svg viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5 text-[#D97757]">
|
|
13
|
+
<path d="M13.827 3.816L20.05 20.2h-3.672l-1.234-3.365H8.856L7.622 20.2H3.95L10.173 3.816h3.654zm-1.827 4.91l-1.989 5.453h3.978l-1.989-5.453z" />
|
|
14
|
+
</svg>
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const GeminiIcon = () => (
|
|
18
|
+
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden="true">
|
|
19
|
+
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" />
|
|
20
|
+
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
|
|
21
|
+
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
|
|
22
|
+
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
|
|
23
|
+
</svg>
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const CodexIcon = () => (
|
|
27
|
+
<svg viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5 text-gray-400">
|
|
28
|
+
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9 6.065 6.065 0 0 0-10.75 2.918 5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.985 5.985 0 0 0 .511 4.91 6.046 6.046 0 0 0 6.515 2.9 5.985 5.985 0 0 0 4.514 2.012 6.046 6.046 0 0 0 5.772-4.206 5.985 5.985 0 0 0 3.998-2.9 6.046 6.046 0 0 0-.747-7.071zM13.26 21.4a4.476 4.476 0 0 1-2.876-1.041l.141-.08 4.779-2.758a.775.775 0 0 0 .393-.681v-6.737l2.02 1.169a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.495 4.493zM3.6 17.275a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.775.775 0 0 0 .781 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-2.675zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973v5.701a.77.77 0 0 0 .388.677l5.815 3.354-2.02 1.169a.076.076 0 0 1-.071 0l-4.83-2.787A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855-5.843-3.354 2.02-1.169a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.402-.695zm2.01-3.023-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.775.775 0 0 0-.393.681zm1.097-2.365 2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5Z" />
|
|
29
|
+
</svg>
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const OpenCodeIcon = () => (
|
|
33
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5 text-emerald-400">
|
|
34
|
+
<polyline points="16 18 22 12 16 6" />
|
|
35
|
+
<polyline points="8 6 2 12 8 18" />
|
|
36
|
+
<line x1="12" y1="4" x2="12" y2="20" strokeWidth="1.4" strokeDasharray="2 2" />
|
|
37
|
+
</svg>
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// ─── Subcomponents ────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
function StatusBadge({ ok, label }: { ok: boolean; label: string }) {
|
|
43
|
+
return ok ? (
|
|
44
|
+
<span className="flex items-center gap-1 rounded-full border border-emerald-500/25 bg-emerald-500/[0.08] px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
|
|
45
|
+
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
|
46
|
+
{label}
|
|
47
|
+
</span>
|
|
48
|
+
) : (
|
|
49
|
+
<span className="flex items-center gap-1 rounded-full border border-red-500/25 bg-red-500/[0.06] px-2 py-0.5 text-[11px] font-medium text-red-500 dark:text-red-400">
|
|
50
|
+
<span className="h-1.5 w-1.5 rounded-full bg-red-500" />
|
|
51
|
+
{label}
|
|
52
|
+
</span>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ComingSoonBadge() {
|
|
57
|
+
return (
|
|
58
|
+
<span className="rounded-full border border-gray-900/[0.08] bg-gray-900/[0.04] px-2 py-0.5 text-[11px] font-medium text-gray-900/30 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white/30">
|
|
59
|
+
준비 중
|
|
60
|
+
</span>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function InfoRow({ label, value }: { label: string; value: string }) {
|
|
65
|
+
return (
|
|
66
|
+
<div className="flex items-center justify-between gap-2">
|
|
67
|
+
<span className="text-[11px] text-gray-900/35 dark:text-white/35">{label}</span>
|
|
68
|
+
<span className="text-[11px] font-medium text-gray-900/65 dark:text-white/65">{value}</span>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function SkeletonCard() {
|
|
74
|
+
return (
|
|
75
|
+
<div className="flex flex-col gap-3 rounded-xl border border-gray-900/[0.07] bg-gray-900/[0.02] p-4 dark:border-white/[0.07] dark:bg-white/[0.02]">
|
|
76
|
+
<div className="flex items-center gap-3">
|
|
77
|
+
<div className="h-9 w-9 animate-pulse rounded-lg bg-gray-900/[0.06] dark:bg-white/[0.06]" />
|
|
78
|
+
<div className="flex flex-1 flex-col gap-1.5">
|
|
79
|
+
<div className="h-3 w-24 animate-pulse rounded bg-gray-900/[0.06] dark:bg-white/[0.06]" />
|
|
80
|
+
<div className="h-2.5 w-16 animate-pulse rounded bg-gray-900/[0.04] dark:bg-white/[0.04]" />
|
|
81
|
+
</div>
|
|
82
|
+
<div className="h-5 w-16 animate-pulse rounded-full bg-gray-900/[0.04] dark:bg-white/[0.04]" />
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Agent Cards ──────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function ClaudeCard({ data, loading }: { data: ClaudeStatus | null; loading: boolean }) {
|
|
91
|
+
if (loading) return <SkeletonCard />;
|
|
92
|
+
|
|
93
|
+
const auth = data?.auth;
|
|
94
|
+
return (
|
|
95
|
+
<div className="flex flex-col gap-3 rounded-xl border border-orange-500/[0.12] bg-orange-500/[0.03] p-4 dark:border-orange-500/[0.10]">
|
|
96
|
+
<div className="flex items-center gap-3">
|
|
97
|
+
<span className="flex h-9 w-9 items-center justify-center rounded-lg border border-gray-900/[0.07] bg-orange-500/[0.10] dark:border-white/[0.07]">
|
|
98
|
+
<ClaudeIcon />
|
|
99
|
+
</span>
|
|
100
|
+
<div className="flex flex-1 flex-col">
|
|
101
|
+
<span className="text-sm font-semibold text-gray-900/85 dark:text-white/85">Claude Code</span>
|
|
102
|
+
<span className="text-[11px] text-gray-900/35 dark:text-white/35">Anthropic</span>
|
|
103
|
+
</div>
|
|
104
|
+
{data ? (
|
|
105
|
+
<StatusBadge ok={auth?.loggedIn ?? false} label={auth?.loggedIn ? "인증됨" : "미인증"} />
|
|
106
|
+
) : (
|
|
107
|
+
<StatusBadge ok={false} label="오프라인" />
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{data && (
|
|
112
|
+
<div className="flex flex-col gap-1.5 border-t border-gray-900/[0.06] pt-3 dark:border-white/[0.06]">
|
|
113
|
+
<InfoRow label="버전" value={data.version} />
|
|
114
|
+
<InfoRow label="플랫폼" value={data.platform} />
|
|
115
|
+
<InfoRow label="활성 세션" value={`${data.activeSessions}개`} />
|
|
116
|
+
{auth?.loggedIn && auth.email && <InfoRow label="계정" value={auth.email} />}
|
|
117
|
+
{auth?.loggedIn && auth.orgName && <InfoRow label="조직" value={auth.orgName} />}
|
|
118
|
+
{auth?.loggedIn && auth.subscriptionType && (
|
|
119
|
+
<InfoRow label="구독" value={auth.subscriptionType} />
|
|
120
|
+
)}
|
|
121
|
+
{auth?.loggedIn && auth.authMethod && (
|
|
122
|
+
<InfoRow label="인증 방식" value={auth.authMethod} />
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function GeminiCard({ data, loading }: { data: GeminiAuthStatus | null; loading: boolean }) {
|
|
131
|
+
if (loading) return <SkeletonCard />;
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div className="flex flex-col gap-3 rounded-xl border border-blue-500/[0.12] bg-blue-500/[0.03] p-4 dark:border-blue-500/[0.10]">
|
|
135
|
+
<div className="flex items-center gap-3">
|
|
136
|
+
<span className="flex h-9 w-9 items-center justify-center rounded-lg border border-gray-900/[0.07] bg-blue-500/[0.08] dark:border-white/[0.07]">
|
|
137
|
+
<GeminiIcon />
|
|
138
|
+
</span>
|
|
139
|
+
<div className="flex flex-1 flex-col">
|
|
140
|
+
<span className="text-sm font-semibold text-gray-900/85 dark:text-white/85">Gemini CLI</span>
|
|
141
|
+
<span className="text-[11px] text-gray-900/35 dark:text-white/35">Google</span>
|
|
142
|
+
</div>
|
|
143
|
+
{data ? (
|
|
144
|
+
data.installed ? (
|
|
145
|
+
<StatusBadge ok={data.loggedIn} label={data.loggedIn ? "인증됨" : "미인증"} />
|
|
146
|
+
) : (
|
|
147
|
+
<StatusBadge ok={false} label="미설치" />
|
|
148
|
+
)
|
|
149
|
+
) : (
|
|
150
|
+
<StatusBadge ok={false} label="오프라인" />
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{data?.installed && (
|
|
155
|
+
<div className="flex flex-col gap-1.5 border-t border-gray-900/[0.06] pt-3 dark:border-white/[0.06]">
|
|
156
|
+
{data.email && <InfoRow label="계정" value={data.email} />}
|
|
157
|
+
{data.authMethod && (
|
|
158
|
+
<InfoRow
|
|
159
|
+
label="인증 방식"
|
|
160
|
+
value={data.authMethod === "api-key" ? "API Key" : data.authMethod === "gca" ? "Google 계정" : data.authMethod}
|
|
161
|
+
/>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function ComingSoonCard({
|
|
170
|
+
icon,
|
|
171
|
+
name,
|
|
172
|
+
vendor,
|
|
173
|
+
iconBg,
|
|
174
|
+
}: {
|
|
175
|
+
icon: React.ReactNode;
|
|
176
|
+
name: string;
|
|
177
|
+
vendor: string;
|
|
178
|
+
iconBg: string;
|
|
179
|
+
}) {
|
|
180
|
+
return (
|
|
181
|
+
<div className="flex items-center gap-3 rounded-xl border border-gray-900/[0.07] bg-gray-900/[0.02] p-4 opacity-50 dark:border-white/[0.07] dark:bg-white/[0.02]">
|
|
182
|
+
<span className={`flex h-9 w-9 items-center justify-center rounded-lg border border-gray-900/[0.07] dark:border-white/[0.07] ${iconBg}`}>
|
|
183
|
+
{icon}
|
|
184
|
+
</span>
|
|
185
|
+
<div className="flex flex-1 flex-col">
|
|
186
|
+
<span className="text-sm font-semibold text-gray-900/85 dark:text-white/85">{name}</span>
|
|
187
|
+
<span className="text-[11px] text-gray-900/35 dark:text-white/35">{vendor}</span>
|
|
188
|
+
</div>
|
|
189
|
+
<ComingSoonBadge />
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ─── Modal ────────────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
interface AgentStatusModalProps {
|
|
197
|
+
open: boolean;
|
|
198
|
+
onClose: () => void;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function AgentStatusModal({ open, onClose }: AgentStatusModalProps) {
|
|
202
|
+
const [claudeStatus, setClaudeStatus] = useState<ClaudeStatus | null>(null);
|
|
203
|
+
const [geminiStatus, setGeminiStatus] = useState<GeminiAuthStatus | null>(null);
|
|
204
|
+
const [loading, setLoading] = useState(false);
|
|
205
|
+
|
|
206
|
+
const refresh = useCallback(async () => {
|
|
207
|
+
setLoading(true);
|
|
208
|
+
try {
|
|
209
|
+
const [claude, gemini] = await Promise.allSettled([getClaudeStatus(), getGeminiAuthStatus()]);
|
|
210
|
+
setClaudeStatus(claude.status === "fulfilled" ? claude.value : null);
|
|
211
|
+
setGeminiStatus(gemini.status === "fulfilled" ? gemini.value : null);
|
|
212
|
+
} finally {
|
|
213
|
+
setLoading(false);
|
|
214
|
+
}
|
|
215
|
+
}, []);
|
|
216
|
+
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
if (open) void refresh();
|
|
219
|
+
}, [open, refresh]);
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<Modal open={open} onClose={onClose} title="에이전트 상태" maxWidth="max-w-md">
|
|
223
|
+
<div className="flex flex-col gap-3">
|
|
224
|
+
{/* Refresh row */}
|
|
225
|
+
<div className="flex items-center justify-between">
|
|
226
|
+
<span className="text-[11px] text-gray-900/30 dark:text-white/30">
|
|
227
|
+
현재 연결된 에이전트 상태입니다
|
|
228
|
+
</span>
|
|
229
|
+
<button
|
|
230
|
+
onClick={() => void refresh()}
|
|
231
|
+
disabled={loading}
|
|
232
|
+
className="flex items-center gap-1 rounded-lg border border-gray-900/[0.08] bg-gray-900/[0.03] px-2.5 py-1 text-[11px] font-medium text-gray-900/45 transition-colors hover:border-gray-900/[0.14] hover:text-gray-900/75 disabled:opacity-40 dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-white/45 dark:hover:border-white/[0.14] dark:hover:text-white/75"
|
|
233
|
+
>
|
|
234
|
+
<svg
|
|
235
|
+
viewBox="0 0 16 16"
|
|
236
|
+
fill="currentColor"
|
|
237
|
+
className={`h-3 w-3 ${loading ? "animate-spin" : ""}`}
|
|
238
|
+
>
|
|
239
|
+
<path
|
|
240
|
+
fillRule="evenodd"
|
|
241
|
+
d="M13.836 2.477a.75.75 0 01.75.75V6.75a.75.75 0 01-.75.75H9.75a.75.75 0 010-1.5h2.765l-.927-.952a5.25 5.25 0 00-8.42 1.199.75.75 0 01-1.32-.71 6.75 6.75 0 0110.824-1.538l.929.952V3.227a.75.75 0 01.75-.75zm-13.08 8.797a.75.75 0 01.75-.75h4.086a.75.75 0 010 1.5H2.776l.927.952a5.25 5.25 0 008.42-1.199.75.75 0 011.32.71 6.75 6.75 0 01-10.824 1.538l-.929-.952v.951a.75.75 0 01-1.5 0V11.274a.75.75 0 01.75-.75l.006.75z"
|
|
242
|
+
clipRule="evenodd"
|
|
243
|
+
/>
|
|
244
|
+
</svg>
|
|
245
|
+
새로고침
|
|
246
|
+
</button>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
{/* Agent cards */}
|
|
250
|
+
<ClaudeCard data={claudeStatus} loading={loading} />
|
|
251
|
+
<GeminiCard data={geminiStatus} loading={loading} />
|
|
252
|
+
<ComingSoonCard
|
|
253
|
+
icon={<CodexIcon />}
|
|
254
|
+
name="Codex CLI"
|
|
255
|
+
vendor="OpenAI"
|
|
256
|
+
iconBg="bg-gray-900/[0.05] dark:bg-white/[0.06]"
|
|
257
|
+
/>
|
|
258
|
+
<ComingSoonCard
|
|
259
|
+
icon={<OpenCodeIcon />}
|
|
260
|
+
name="OpenCode"
|
|
261
|
+
vendor="OpenCode"
|
|
262
|
+
iconBg="bg-emerald-500/[0.08]"
|
|
263
|
+
/>
|
|
264
|
+
</div>
|
|
265
|
+
</Modal>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import * as authApi from "@/features/auth/api/auth.api";
|
|
6
|
+
import { AgentStatusModal } from "../AgentStatusModal";
|
|
7
|
+
|
|
8
|
+
vi.mock("@/features/auth/api/auth.api", () => ({
|
|
9
|
+
getClaudeStatus: vi.fn(),
|
|
10
|
+
getGeminiAuthStatus: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
const mockClaude = vi.mocked(authApi.getClaudeStatus);
|
|
14
|
+
const mockGemini = vi.mocked(authApi.getGeminiAuthStatus);
|
|
15
|
+
|
|
16
|
+
afterEach(() => { vi.clearAllMocks(); });
|
|
17
|
+
|
|
18
|
+
describe("AgentStatusModal", () => {
|
|
19
|
+
it("does not render when closed", () => {
|
|
20
|
+
render(<AgentStatusModal open={false} onClose={vi.fn()} />);
|
|
21
|
+
expect(screen.queryByText("에이전트 상태")).not.toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("loads and renders Claude/Gemini status plus coming soon cards", async () => {
|
|
25
|
+
mockClaude.mockResolvedValue({
|
|
26
|
+
version: "1.0.0",
|
|
27
|
+
platform: "darwin",
|
|
28
|
+
activeSessions: 2,
|
|
29
|
+
auth: {
|
|
30
|
+
loggedIn: true,
|
|
31
|
+
authMethod: "oauth",
|
|
32
|
+
apiProvider: "anthropic",
|
|
33
|
+
email: "user@example.com",
|
|
34
|
+
orgName: "Org",
|
|
35
|
+
subscriptionType: "pro",
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
mockGemini.mockResolvedValue({
|
|
39
|
+
installed: true,
|
|
40
|
+
loggedIn: true,
|
|
41
|
+
authMethod: "api-key",
|
|
42
|
+
email: "gemini@example.com",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
render(<AgentStatusModal open={true} onClose={vi.fn()} />);
|
|
46
|
+
|
|
47
|
+
expect(await screen.findByText("Claude Code")).toBeInTheDocument();
|
|
48
|
+
expect(screen.getByText("1.0.0")).toBeInTheDocument();
|
|
49
|
+
expect(screen.getByText("user@example.com")).toBeInTheDocument();
|
|
50
|
+
expect(screen.getByText("Gemini CLI")).toBeInTheDocument();
|
|
51
|
+
expect(screen.getByText("gemini@example.com")).toBeInTheDocument();
|
|
52
|
+
expect(screen.getByText("API Key")).toBeInTheDocument();
|
|
53
|
+
expect(screen.getByText("Codex CLI")).toBeInTheDocument();
|
|
54
|
+
expect(screen.getAllByText("OpenCode")).toHaveLength(2);
|
|
55
|
+
expect(screen.getAllByText("준비 중")).toHaveLength(2);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("refreshes and renders offline/missing states when requests fail", async () => {
|
|
59
|
+
const user = userEvent.setup();
|
|
60
|
+
mockClaude.mockRejectedValue(new Error("offline"));
|
|
61
|
+
mockGemini.mockResolvedValue({ installed: false, loggedIn: false, authMethod: "none" });
|
|
62
|
+
|
|
63
|
+
render(<AgentStatusModal open={true} onClose={vi.fn()} />);
|
|
64
|
+
|
|
65
|
+
expect(await screen.findByText("오프라인")).toBeInTheDocument();
|
|
66
|
+
expect(screen.getByText("미설치")).toBeInTheDocument();
|
|
67
|
+
|
|
68
|
+
await user.click(screen.getByRole("button", { name: "새로고침" }));
|
|
69
|
+
await waitFor(() => expect(mockClaude).toHaveBeenCalledTimes(2));
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { fetchTaskChangelog, fetchTaskRunChangelog, mergeAgentAll, mergeAgentFile } from "../changelog.api";
|
|
4
|
+
|
|
5
|
+
const mockFetch = vi.fn();
|
|
6
|
+
|
|
7
|
+
beforeEach(() => { vi.stubGlobal("fetch", mockFetch); });
|
|
8
|
+
afterEach(() => { vi.unstubAllGlobals(); });
|
|
9
|
+
|
|
10
|
+
function ok(body: unknown) {
|
|
11
|
+
return Promise.resolve({ ok: true, json: () => Promise.resolve(body) } as Response);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function err(status: number) {
|
|
15
|
+
return Promise.resolve({ ok: false, status } as Response);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("changelog api", () => {
|
|
19
|
+
it("fetchTaskChangelog passes the task id and abort signal", async () => {
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
const body = [{ agentId: 1, files: [] }];
|
|
22
|
+
mockFetch.mockReturnValueOnce(ok(body));
|
|
23
|
+
|
|
24
|
+
const result = await fetchTaskChangelog("task-1", controller.signal);
|
|
25
|
+
|
|
26
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
27
|
+
expect.stringContaining("/tasks/task-1/changelog"),
|
|
28
|
+
{ signal: controller.signal },
|
|
29
|
+
);
|
|
30
|
+
expect(result).toEqual(body);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("fetchTaskChangelog throws on HTTP error", async () => {
|
|
34
|
+
mockFetch.mockReturnValueOnce(err(404));
|
|
35
|
+
await expect(fetchTaskChangelog("missing")).rejects.toThrow("HTTP 404");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("fetchTaskRunChangelog passes the run id and abort signal", async () => {
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const body = [{ agentId: 1, files: [] }];
|
|
41
|
+
mockFetch.mockReturnValueOnce(ok(body));
|
|
42
|
+
|
|
43
|
+
const result = await fetchTaskRunChangelog("task-1", 3, controller.signal);
|
|
44
|
+
|
|
45
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
46
|
+
expect.stringContaining("/tasks/task-1/runs/3/changelog"),
|
|
47
|
+
{ signal: controller.signal },
|
|
48
|
+
);
|
|
49
|
+
expect(result).toEqual(body);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("mergeAgentAll posts to the agent merge endpoint", async () => {
|
|
53
|
+
const body = { success: true, message: "merged" };
|
|
54
|
+
mockFetch.mockReturnValueOnce(ok(body));
|
|
55
|
+
|
|
56
|
+
const result = await mergeAgentAll("task-1", 7);
|
|
57
|
+
|
|
58
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
59
|
+
expect.stringContaining("/tasks/task-1/agents/7/merge"),
|
|
60
|
+
{ method: "POST" },
|
|
61
|
+
);
|
|
62
|
+
expect(result).toEqual(body);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("mergeAgentFile posts a JSON filePath body", async () => {
|
|
66
|
+
const body = { success: true, message: "merged file" };
|
|
67
|
+
mockFetch.mockReturnValueOnce(ok(body));
|
|
68
|
+
|
|
69
|
+
const result = await mergeAgentFile("task-1", 7, "src/App.tsx");
|
|
70
|
+
|
|
71
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
72
|
+
expect.stringContaining("/tasks/task-1/agents/7/merge-file"),
|
|
73
|
+
{
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "Content-Type": "application/json" },
|
|
76
|
+
body: JSON.stringify({ filePath: "src/App.tsx" }),
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
expect(result).toEqual(body);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("merge endpoints throw on HTTP errors", async () => {
|
|
83
|
+
mockFetch.mockReturnValueOnce(err(409));
|
|
84
|
+
await expect(mergeAgentAll("task-1", 1)).rejects.toThrow("HTTP 409");
|
|
85
|
+
|
|
86
|
+
mockFetch.mockReturnValueOnce(err(500));
|
|
87
|
+
await expect(mergeAgentFile("task-1", 1, "a.ts")).rejects.toThrow("HTTP 500");
|
|
88
|
+
});
|
|
89
|
+
});
|