create-claude-pipeline 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +359 -0
- package/package.json +32 -0
- package/template/.claude/agents/be-developer.md +218 -0
- package/template/.claude/agents/designer.md +192 -0
- package/template/.claude/agents/fe-developer.md +175 -0
- package/template/.claude/agents/infra-developer.md +270 -0
- package/template/.claude/agents/planner.md +126 -0
- package/template/.claude/agents/pm.md +130 -0
- package/template/.claude/agents/qa-engineer.md +270 -0
- package/template/.claude/agents/security-reviewer.md +281 -0
- package/template/.claude/settings.json +5 -0
- package/template/.claude/skills/analyze-requirements/SKILL.md +166 -0
- package/template/.claude/skills/api-integration/SKILL.md +354 -0
- package/template/.claude/skills/assemble-context/SKILL.md +192 -0
- package/template/.claude/skills/db-migration/SKILL.md +228 -0
- package/template/.claude/skills/explore-be-codebase/SKILL.md +260 -0
- package/template/.claude/skills/explore-codebase/SKILL.md +190 -0
- package/template/.claude/skills/explore-design-system/SKILL.md +150 -0
- package/template/.claude/skills/explore-fe-codebase/SKILL.md +209 -0
- package/template/.claude/skills/explore-implementation/SKILL.md +147 -0
- package/template/.claude/skills/explore-infra/SKILL.md +242 -0
- package/template/.claude/skills/implement-api/SKILL.md +477 -0
- package/template/.claude/skills/implement-components/SKILL.md +217 -0
- package/template/.claude/skills/review-auth/SKILL.md +175 -0
- package/template/.claude/skills/scan-vulnerabilities/SKILL.md +200 -0
- package/template/.claude/skills/write-cicd/SKILL.md +293 -0
- package/template/.claude/skills/write-design-spec/SKILL.md +363 -0
- package/template/.claude/skills/write-dockerfile/SKILL.md +269 -0
- package/template/.claude/skills/write-plan-doc/SKILL.md +164 -0
- package/template/.claude/skills/write-plan-doc/assets/plan_template.html +251 -0
- package/template/.claude/skills/write-qa-report/SKILL.md +151 -0
- package/template/.claude/skills/write-security-report/SKILL.md +185 -0
- package/template/.claude/skills/write-test-cases/SKILL.md +234 -0
- package/template/.claude-pipeline/dashboard/.env.example +1 -0
- package/template/.claude-pipeline/dashboard/.eslintrc.json +3 -0
- package/template/.claude-pipeline/dashboard/README.md +36 -0
- package/template/.claude-pipeline/dashboard/next.config.mjs +6 -0
- package/template/.claude-pipeline/dashboard/package-lock.json +8148 -0
- package/template/.claude-pipeline/dashboard/package.json +36 -0
- package/template/.claude-pipeline/dashboard/postcss.config.mjs +8 -0
- package/template/.claude-pipeline/dashboard/server.ts +24 -0
- package/template/.claude-pipeline/dashboard/src/app/api/pipelines/[id]/checkpoint/route.ts +23 -0
- package/template/.claude-pipeline/dashboard/src/app/api/pipelines/[id]/outputs/[...filepath]/route.ts +18 -0
- package/template/.claude-pipeline/dashboard/src/app/api/pipelines/[id]/route.ts +10 -0
- package/template/.claude-pipeline/dashboard/src/app/api/pipelines/route.ts +64 -0
- package/template/.claude-pipeline/dashboard/src/app/favicon.ico +0 -0
- package/template/.claude-pipeline/dashboard/src/app/fonts/GeistMonoVF.woff +0 -0
- package/template/.claude-pipeline/dashboard/src/app/fonts/GeistVF.woff +0 -0
- package/template/.claude-pipeline/dashboard/src/app/globals.css +52 -0
- package/template/.claude-pipeline/dashboard/src/app/layout.tsx +33 -0
- package/template/.claude-pipeline/dashboard/src/app/page.tsx +49 -0
- package/template/.claude-pipeline/dashboard/src/app/pipeline/[id]/page.tsx +84 -0
- package/template/.claude-pipeline/dashboard/src/components/agent-card.tsx +40 -0
- package/template/.claude-pipeline/dashboard/src/components/agent-logs.tsx +65 -0
- package/template/.claude-pipeline/dashboard/src/components/artifact-viewer.tsx +130 -0
- package/template/.claude-pipeline/dashboard/src/components/checkpoint-banner.tsx +59 -0
- package/template/.claude-pipeline/dashboard/src/components/new-pipeline-modal.tsx +63 -0
- package/template/.claude-pipeline/dashboard/src/components/output-list.tsx +57 -0
- package/template/.claude-pipeline/dashboard/src/components/phase-dots.tsx +37 -0
- package/template/.claude-pipeline/dashboard/src/components/pipeline-card.tsx +53 -0
- package/template/.claude-pipeline/dashboard/src/components/resizable-panels.tsx +91 -0
- package/template/.claude-pipeline/dashboard/src/hooks/use-pipeline-detail.ts +65 -0
- package/template/.claude-pipeline/dashboard/src/hooks/use-pipelines.ts +60 -0
- package/template/.claude-pipeline/dashboard/src/hooks/use-websocket.ts +58 -0
- package/template/.claude-pipeline/dashboard/src/lib/agents.ts +30 -0
- package/template/.claude-pipeline/dashboard/src/lib/checkpoint.ts +37 -0
- package/template/.claude-pipeline/dashboard/src/lib/pipelines.ts +91 -0
- package/template/.claude-pipeline/dashboard/src/lib/watcher.ts +90 -0
- package/template/.claude-pipeline/dashboard/src/lib/ws-server.ts +123 -0
- package/template/.claude-pipeline/dashboard/src/types/pipeline.ts +61 -0
- package/template/.claude-pipeline/dashboard/tailwind.config.ts +31 -0
- package/template/.claude-pipeline/dashboard/tsconfig.json +26 -0
- package/template/CLAUDE.md +301 -0
- package/template/references/context-structure.md +34 -0
- package/template/references/pm-context-assembly.md +34 -0
- package/template/references/task-context-template.md +65 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { CheckpointInfo } from "@/types/pipeline";
|
|
5
|
+
|
|
6
|
+
interface CheckpointBannerProps {
|
|
7
|
+
checkpoint: CheckpointInfo;
|
|
8
|
+
onRespond: (action: "approve" | "reject", message?: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function CheckpointBanner({ checkpoint, onRespond }: CheckpointBannerProps) {
|
|
12
|
+
const [showFeedback, setShowFeedback] = useState(false);
|
|
13
|
+
const [feedback, setFeedback] = useState("");
|
|
14
|
+
|
|
15
|
+
if (showFeedback) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="mx-4 mb-3 bg-gradient-to-r from-accent-purple/10 to-accent-purple-light/10 border border-accent-purple rounded-lg p-4">
|
|
18
|
+
<div className="text-accent-purple-light text-[12px] font-semibold mb-2">피드백 작성</div>
|
|
19
|
+
<textarea
|
|
20
|
+
value={feedback}
|
|
21
|
+
onChange={(e) => setFeedback(e.target.value)}
|
|
22
|
+
className="w-full bg-[#111827] border border-border rounded-lg p-2 text-text-primary text-sm resize-none h-20 focus:outline-none focus:border-accent-purple"
|
|
23
|
+
placeholder="피드백을 입력하세요..."
|
|
24
|
+
autoFocus
|
|
25
|
+
/>
|
|
26
|
+
<div className="flex justify-end gap-2 mt-2">
|
|
27
|
+
<button onClick={() => setShowFeedback(false)} className="px-3 py-1 text-[11px] text-text-secondary bg-border rounded-md">
|
|
28
|
+
취소
|
|
29
|
+
</button>
|
|
30
|
+
<button
|
|
31
|
+
onClick={() => onRespond("reject", feedback)}
|
|
32
|
+
className="px-3 py-1 text-[11px] text-white bg-gradient-to-r from-accent-purple to-accent-purple-light rounded-md"
|
|
33
|
+
>
|
|
34
|
+
전송
|
|
35
|
+
</button>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="mx-4 mb-3 bg-gradient-to-r from-accent-purple/10 to-accent-purple-light/10 border border-accent-purple rounded-lg px-4 py-3 flex justify-between items-center">
|
|
43
|
+
<div>
|
|
44
|
+
<div className="text-accent-purple-light text-[12px] font-semibold">
|
|
45
|
+
⏳ CHECKPOINT {checkpoint.phase}
|
|
46
|
+
</div>
|
|
47
|
+
<div className="text-text-primary text-[12px] mt-1">{checkpoint.description}</div>
|
|
48
|
+
</div>
|
|
49
|
+
<div className="flex gap-2">
|
|
50
|
+
<button onClick={() => setShowFeedback(true)} className="px-3 py-1 text-[11px] text-text-primary bg-border rounded-md hover:bg-text-muted/30">
|
|
51
|
+
피드백
|
|
52
|
+
</button>
|
|
53
|
+
<button onClick={() => onRespond("approve")} className="px-3 py-1 text-[11px] text-white bg-gradient-to-r from-accent-purple to-accent-purple-light rounded-md">
|
|
54
|
+
승인
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
|
|
6
|
+
interface NewPipelineModalProps {
|
|
7
|
+
open: boolean;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function NewPipelineModal({ open, onClose }: NewPipelineModalProps) {
|
|
12
|
+
const [requirements, setRequirements] = useState("");
|
|
13
|
+
const [loading, setLoading] = useState(false);
|
|
14
|
+
const router = useRouter();
|
|
15
|
+
|
|
16
|
+
if (!open) return null;
|
|
17
|
+
|
|
18
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
19
|
+
e.preventDefault();
|
|
20
|
+
if (!requirements.trim() || loading) return;
|
|
21
|
+
|
|
22
|
+
setLoading(true);
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch("/api/pipelines", {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "Content-Type": "application/json" },
|
|
27
|
+
body: JSON.stringify({ requirements: requirements.trim() }),
|
|
28
|
+
});
|
|
29
|
+
const data = await res.json();
|
|
30
|
+
if (res.ok) {
|
|
31
|
+
router.push(`/pipeline/${data.id}`);
|
|
32
|
+
onClose();
|
|
33
|
+
}
|
|
34
|
+
} finally {
|
|
35
|
+
setLoading(false);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
|
41
|
+
<div className="bg-panel border border-border rounded-lg p-6 w-full max-w-md" onClick={(e) => e.stopPropagation()}>
|
|
42
|
+
<h2 className="text-text-primary text-lg font-semibold mb-4">New Pipeline</h2>
|
|
43
|
+
<form onSubmit={handleSubmit}>
|
|
44
|
+
<textarea
|
|
45
|
+
value={requirements}
|
|
46
|
+
onChange={(e) => setRequirements(e.target.value)}
|
|
47
|
+
placeholder="요구사항을 입력하세요..."
|
|
48
|
+
className="w-full bg-[#111827] border border-border rounded-lg p-3 text-text-primary text-sm resize-none h-32 focus:outline-none focus:border-accent-purple"
|
|
49
|
+
autoFocus
|
|
50
|
+
/>
|
|
51
|
+
<div className="flex justify-end gap-2 mt-4">
|
|
52
|
+
<button type="button" onClick={onClose} className="px-4 py-2 text-sm text-text-secondary bg-border rounded-lg hover:bg-text-muted/30">
|
|
53
|
+
취소
|
|
54
|
+
</button>
|
|
55
|
+
<button type="submit" disabled={!requirements.trim() || loading} className="px-4 py-2 text-sm text-white bg-gradient-to-r from-accent-purple to-accent-purple-light rounded-lg disabled:opacity-50">
|
|
56
|
+
{loading ? "생성 중..." : "생성"}
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
</form>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { OutputEntry } from "@/types/pipeline";
|
|
4
|
+
import { PHASE_NAMES } from "@/lib/agents";
|
|
5
|
+
|
|
6
|
+
interface OutputListProps {
|
|
7
|
+
outputs: OutputEntry[];
|
|
8
|
+
onSelect: (filename: string) => void;
|
|
9
|
+
selected?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function OutputList({ outputs, onSelect, selected }: OutputListProps) {
|
|
13
|
+
const grouped = outputs.reduce((acc, o) => {
|
|
14
|
+
(acc[o.phase] ||= []).push(o);
|
|
15
|
+
return acc;
|
|
16
|
+
}, {} as Record<number, OutputEntry[]>);
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="flex flex-col h-full">
|
|
20
|
+
<div className="px-3 py-2 border-b border-border">
|
|
21
|
+
<div className="text-text-secondary text-[11px] font-semibold">OUTPUTS</div>
|
|
22
|
+
</div>
|
|
23
|
+
<div className="flex-1 px-3 py-2 overflow-y-auto">
|
|
24
|
+
{outputs.length === 0 ? (
|
|
25
|
+
<div className="text-text-muted text-[11px] text-center py-10">아직 산출물이 없습니다.</div>
|
|
26
|
+
) : (
|
|
27
|
+
Object.entries(grouped)
|
|
28
|
+
.sort(([a], [b]) => Number(a) - Number(b))
|
|
29
|
+
.map(([phase, files]) => (
|
|
30
|
+
<div key={phase}>
|
|
31
|
+
<div className="text-text-secondary text-[9px] font-semibold mt-2 mb-1">
|
|
32
|
+
PHASE {phase} {PHASE_NAMES[Number(phase)] || ""}
|
|
33
|
+
</div>
|
|
34
|
+
{files.map((f) => {
|
|
35
|
+
const icon = f.filename.endsWith(".html") ? "🌐" : "📄";
|
|
36
|
+
const name = f.filename.split("/").pop();
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
key={f.filename}
|
|
40
|
+
onClick={() => onSelect(f.filename)}
|
|
41
|
+
className={`px-2 py-1 rounded text-[11px] cursor-pointer ${
|
|
42
|
+
selected === f.filename
|
|
43
|
+
? "bg-border text-accent-purple-light"
|
|
44
|
+
: "text-text-primary hover:bg-panel"
|
|
45
|
+
}`}
|
|
46
|
+
>
|
|
47
|
+
{icon} {name}
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
})}
|
|
51
|
+
</div>
|
|
52
|
+
))
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { PHASE_NAMES } from "@/lib/agents";
|
|
4
|
+
|
|
5
|
+
interface PhaseDotsProp {
|
|
6
|
+
currentPhase: number;
|
|
7
|
+
showLabel?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function PhaseDots({ currentPhase, showLabel = false }: PhaseDotsProp) {
|
|
11
|
+
return (
|
|
12
|
+
<div className="flex items-center gap-[6px]">
|
|
13
|
+
{PHASE_NAMES.map((name, i) => {
|
|
14
|
+
const isComplete = i < currentPhase;
|
|
15
|
+
const isCurrent = i === currentPhase;
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
key={i}
|
|
19
|
+
className={`rounded-full ${
|
|
20
|
+
isCurrent
|
|
21
|
+
? "w-[10px] h-[10px] bg-accent-purple-light shadow-[0_0_6px_rgba(139,92,246,0.5)]"
|
|
22
|
+
: isComplete
|
|
23
|
+
? "w-2 h-2 bg-accent-purple"
|
|
24
|
+
: "w-2 h-2 bg-border"
|
|
25
|
+
}`}
|
|
26
|
+
title={`P${i} ${name}`}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
})}
|
|
30
|
+
{showLabel && (
|
|
31
|
+
<span className="text-text-secondary text-[11px] ml-1">
|
|
32
|
+
P{currentPhase} {PHASE_NAMES[currentPhase]}
|
|
33
|
+
</span>
|
|
34
|
+
)}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { PhaseDots } from "./phase-dots";
|
|
5
|
+
import { AGENT_MAP } from "@/lib/agents";
|
|
6
|
+
import type { PipelineSummary } from "@/types/pipeline";
|
|
7
|
+
|
|
8
|
+
function timeAgo(dateStr: string): string {
|
|
9
|
+
const diff = Date.now() - new Date(dateStr).getTime();
|
|
10
|
+
const mins = Math.floor(diff / 60000);
|
|
11
|
+
if (mins < 60) return `${mins}m ago`;
|
|
12
|
+
const hours = Math.floor(mins / 60);
|
|
13
|
+
if (hours < 24) return `${hours}h ago`;
|
|
14
|
+
const days = Math.floor(hours / 24);
|
|
15
|
+
return `${days}d ago`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function PipelineCard({ pipeline }: { pipeline: PipelineSummary }) {
|
|
19
|
+
const workingAgents = Object.values(pipeline.agents)
|
|
20
|
+
.filter((a) => a.status === "working")
|
|
21
|
+
.map((a) => AGENT_MAP[a.id]?.emoji)
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
|
|
24
|
+
const statusColor =
|
|
25
|
+
pipeline.status === "running" ? "text-accent-green" :
|
|
26
|
+
pipeline.status === "completed" ? "text-text-muted" :
|
|
27
|
+
pipeline.status === "failed" ? "text-red-500" :
|
|
28
|
+
"text-yellow-500";
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<Link href={`/pipeline/${pipeline.id}`}>
|
|
32
|
+
<div className="bg-panel p-4 rounded-lg border border-border hover:border-accent-purple/50 transition-colors cursor-pointer flex justify-between items-center">
|
|
33
|
+
<div>
|
|
34
|
+
<div className="flex items-center gap-2 mb-1">
|
|
35
|
+
<span className={`text-[11px] ${statusColor}`}>
|
|
36
|
+
● {pipeline.status.toUpperCase()}
|
|
37
|
+
</span>
|
|
38
|
+
<span className="text-text-primary text-[13px] font-medium">
|
|
39
|
+
{pipeline.requirements}
|
|
40
|
+
</span>
|
|
41
|
+
</div>
|
|
42
|
+
<div className="text-text-muted text-[11px]">
|
|
43
|
+
Phase {pipeline.currentPhase}
|
|
44
|
+
{workingAgents.length > 0 && ` · ${workingAgents.join("")} working`}
|
|
45
|
+
{" · "}
|
|
46
|
+
{timeAgo(pipeline.createdAt)}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
<PhaseDots currentPhase={pipeline.currentPhase} />
|
|
50
|
+
</div>
|
|
51
|
+
</Link>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useCallback, useEffect, ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
interface ResizablePanelsProps {
|
|
6
|
+
left: ReactNode;
|
|
7
|
+
center: ReactNode;
|
|
8
|
+
right: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const STORAGE_KEY = "panel-sizes";
|
|
12
|
+
const DEFAULT_SIZES = [25, 50, 25];
|
|
13
|
+
const MIN_SIZES = [160, 200, 180];
|
|
14
|
+
|
|
15
|
+
export function ResizablePanels({ left, center, right }: ResizablePanelsProps) {
|
|
16
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
17
|
+
const [sizes, setSizes] = useState(DEFAULT_SIZES);
|
|
18
|
+
const dragState = useRef<{ index: number; startX: number; startSizes: number[] } | null>(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const saved = localStorage.getItem(STORAGE_KEY);
|
|
22
|
+
if (saved) {
|
|
23
|
+
try {
|
|
24
|
+
setSizes(JSON.parse(saved));
|
|
25
|
+
} catch { /* ignore */ }
|
|
26
|
+
}
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(sizes));
|
|
31
|
+
}, [sizes]);
|
|
32
|
+
|
|
33
|
+
const onMouseDown = useCallback((index: number, e: React.MouseEvent) => {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
dragState.current = { index, startX: e.clientX, startSizes: [...sizes] };
|
|
36
|
+
|
|
37
|
+
const onMouseMove = (e: MouseEvent) => {
|
|
38
|
+
if (!dragState.current || !containerRef.current) return;
|
|
39
|
+
const { index, startX, startSizes } = dragState.current;
|
|
40
|
+
const containerWidth = containerRef.current.offsetWidth;
|
|
41
|
+
const deltaPct = ((e.clientX - startX) / containerWidth) * 100;
|
|
42
|
+
|
|
43
|
+
const newSizes = [...startSizes];
|
|
44
|
+
newSizes[index] = Math.max((MIN_SIZES[index] / containerWidth) * 100, startSizes[index] + deltaPct);
|
|
45
|
+
newSizes[index + 1] = Math.max((MIN_SIZES[index + 1] / containerWidth) * 100, startSizes[index + 1] - deltaPct);
|
|
46
|
+
|
|
47
|
+
setSizes(newSizes);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const onMouseUp = () => {
|
|
51
|
+
dragState.current = null;
|
|
52
|
+
document.removeEventListener("mousemove", onMouseMove);
|
|
53
|
+
document.removeEventListener("mouseup", onMouseUp);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
document.addEventListener("mousemove", onMouseMove);
|
|
57
|
+
document.addEventListener("mouseup", onMouseUp);
|
|
58
|
+
}, [sizes]);
|
|
59
|
+
|
|
60
|
+
const onDoubleClick = useCallback(() => {
|
|
61
|
+
setSizes(DEFAULT_SIZES);
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div ref={containerRef} className="flex h-full">
|
|
66
|
+
<div style={{ width: `${sizes[0]}%` }} className="overflow-y-auto">
|
|
67
|
+
{left}
|
|
68
|
+
</div>
|
|
69
|
+
<div
|
|
70
|
+
className="w-[6px] bg-panel cursor-col-resize flex items-center justify-center flex-shrink-0 hover:bg-border transition-colors"
|
|
71
|
+
onMouseDown={(e) => onMouseDown(0, e)}
|
|
72
|
+
onDoubleClick={onDoubleClick}
|
|
73
|
+
>
|
|
74
|
+
<span className="text-text-muted text-[9px] writing-mode-vertical select-none">⋮⋮</span>
|
|
75
|
+
</div>
|
|
76
|
+
<div style={{ width: `${sizes[1]}%` }} className="overflow-hidden flex flex-col">
|
|
77
|
+
{center}
|
|
78
|
+
</div>
|
|
79
|
+
<div
|
|
80
|
+
className="w-[6px] bg-panel cursor-col-resize flex items-center justify-center flex-shrink-0 hover:bg-border transition-colors"
|
|
81
|
+
onMouseDown={(e) => onMouseDown(1, e)}
|
|
82
|
+
onDoubleClick={onDoubleClick}
|
|
83
|
+
>
|
|
84
|
+
<span className="text-text-muted text-[9px] writing-mode-vertical select-none">⋮⋮</span>
|
|
85
|
+
</div>
|
|
86
|
+
<div style={{ width: `${sizes[2]}%` }} className="overflow-y-auto">
|
|
87
|
+
{right}
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from "react";
|
|
4
|
+
import type { PipelineState, CheckpointInfo, ServerMessage } from "@/types/pipeline";
|
|
5
|
+
import { useWebSocket } from "./use-websocket";
|
|
6
|
+
|
|
7
|
+
export function usePipelineDetail(id: string) {
|
|
8
|
+
const [pipeline, setPipeline] = useState<PipelineState | null>(null);
|
|
9
|
+
const [checkpoint, setCheckpoint] = useState<CheckpointInfo | null>(null);
|
|
10
|
+
const [loading, setLoading] = useState(true);
|
|
11
|
+
const [notFound, setNotFound] = useState(false);
|
|
12
|
+
|
|
13
|
+
const fetchPipeline = useCallback(async () => {
|
|
14
|
+
try {
|
|
15
|
+
const res = await fetch(`/api/pipelines/${id}`);
|
|
16
|
+
if (res.status === 404) {
|
|
17
|
+
setNotFound(true);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const data = await res.json();
|
|
21
|
+
setPipeline(data);
|
|
22
|
+
} catch {
|
|
23
|
+
// Ignore
|
|
24
|
+
} finally {
|
|
25
|
+
setLoading(false);
|
|
26
|
+
}
|
|
27
|
+
}, [id]);
|
|
28
|
+
|
|
29
|
+
const handleMessage = useCallback((msg: ServerMessage) => {
|
|
30
|
+
if (msg.type === "pipeline:updated" && msg.id === id) {
|
|
31
|
+
setPipeline(msg.state);
|
|
32
|
+
} else if (msg.type === "pipeline:activity" && msg.id === id) {
|
|
33
|
+
setPipeline((prev) => {
|
|
34
|
+
if (!prev) return prev;
|
|
35
|
+
return { ...prev, activities: [...prev.activities, msg.activity] };
|
|
36
|
+
});
|
|
37
|
+
} else if (msg.type === "pipeline:checkpoint" && msg.id === id) {
|
|
38
|
+
setCheckpoint(msg.checkpoint);
|
|
39
|
+
} else if (msg.type === "pipeline:removed" && msg.id === id) {
|
|
40
|
+
setNotFound(true);
|
|
41
|
+
}
|
|
42
|
+
}, [id]);
|
|
43
|
+
|
|
44
|
+
const { send, connected } = useWebSocket(handleMessage, fetchPipeline);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
fetchPipeline();
|
|
48
|
+
}, [fetchPipeline]);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (connected) {
|
|
52
|
+
send({ type: "subscribe", pipelineId: id });
|
|
53
|
+
return () => {
|
|
54
|
+
send({ type: "unsubscribe", pipelineId: id });
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}, [connected, id, send]);
|
|
58
|
+
|
|
59
|
+
const respondToCheckpoint = useCallback((action: "approve" | "reject", message?: string) => {
|
|
60
|
+
send({ type: "checkpoint:respond", pipelineId: id, action, message });
|
|
61
|
+
setCheckpoint(null);
|
|
62
|
+
}, [id, send]);
|
|
63
|
+
|
|
64
|
+
return { pipeline, checkpoint, loading, notFound, respondToCheckpoint };
|
|
65
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from "react";
|
|
4
|
+
import type { PipelineSummary, ServerMessage } from "@/types/pipeline";
|
|
5
|
+
import { useWebSocket } from "./use-websocket";
|
|
6
|
+
|
|
7
|
+
export function usePipelines() {
|
|
8
|
+
const [pipelines, setPipelines] = useState<PipelineSummary[]>([]);
|
|
9
|
+
const [loading, setLoading] = useState(true);
|
|
10
|
+
|
|
11
|
+
const fetchPipelines = useCallback(async () => {
|
|
12
|
+
try {
|
|
13
|
+
const res = await fetch("/api/pipelines");
|
|
14
|
+
const data = await res.json();
|
|
15
|
+
setPipelines(data.pipelines);
|
|
16
|
+
} catch {
|
|
17
|
+
// Ignore fetch errors
|
|
18
|
+
} finally {
|
|
19
|
+
setLoading(false);
|
|
20
|
+
}
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
const handleMessage = useCallback((msg: ServerMessage) => {
|
|
24
|
+
if (msg.type === "pipeline:updated") {
|
|
25
|
+
setPipelines((prev) => {
|
|
26
|
+
const idx = prev.findIndex((p) => p.id === msg.id);
|
|
27
|
+
const summary: PipelineSummary = {
|
|
28
|
+
id: msg.state.id,
|
|
29
|
+
requirements: msg.state.requirements,
|
|
30
|
+
status: msg.state.status,
|
|
31
|
+
currentPhase: msg.state.currentPhase,
|
|
32
|
+
createdAt: msg.state.createdAt,
|
|
33
|
+
agents: msg.state.agents,
|
|
34
|
+
};
|
|
35
|
+
if (idx >= 0) {
|
|
36
|
+
const next = [...prev];
|
|
37
|
+
next[idx] = summary;
|
|
38
|
+
return next;
|
|
39
|
+
}
|
|
40
|
+
return [summary, ...prev];
|
|
41
|
+
});
|
|
42
|
+
} else if (msg.type === "pipeline:removed") {
|
|
43
|
+
setPipelines((prev) => prev.filter((p) => p.id !== msg.id));
|
|
44
|
+
}
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
const { send, connected } = useWebSocket(handleMessage, fetchPipelines);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
fetchPipelines();
|
|
51
|
+
}, [fetchPipelines]);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (connected) {
|
|
55
|
+
send({ type: "subscribe:all" });
|
|
56
|
+
}
|
|
57
|
+
}, [connected, send]);
|
|
58
|
+
|
|
59
|
+
return { pipelines, loading };
|
|
60
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useCallback, useState } from "react";
|
|
4
|
+
import type { ServerMessage, ClientMessage } from "@/types/pipeline";
|
|
5
|
+
|
|
6
|
+
export function useWebSocket(onMessage: (msg: ServerMessage) => void, onReconnect?: () => void) {
|
|
7
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
8
|
+
const reconnectDelay = useRef(1000);
|
|
9
|
+
const [connected, setConnected] = useState(false);
|
|
10
|
+
|
|
11
|
+
const connect = useCallback(() => {
|
|
12
|
+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
13
|
+
const ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
|
|
14
|
+
|
|
15
|
+
ws.onopen = () => {
|
|
16
|
+
const wasDisconnected = !connected;
|
|
17
|
+
setConnected(true);
|
|
18
|
+
reconnectDelay.current = 1000;
|
|
19
|
+
// Signal reconnect so data hooks can re-fetch
|
|
20
|
+
if (wasDisconnected && onReconnect) onReconnect();
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
ws.onmessage = (event) => {
|
|
24
|
+
try {
|
|
25
|
+
const msg = JSON.parse(event.data) as ServerMessage;
|
|
26
|
+
onMessage(msg);
|
|
27
|
+
} catch {
|
|
28
|
+
// Ignore parse errors
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
ws.onclose = () => {
|
|
33
|
+
setConnected(false);
|
|
34
|
+
wsRef.current = null;
|
|
35
|
+
// Exponential backoff reconnection
|
|
36
|
+
const delay = reconnectDelay.current;
|
|
37
|
+
reconnectDelay.current = Math.min(delay * 2, 30000);
|
|
38
|
+
setTimeout(connect, delay);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
wsRef.current = ws;
|
|
42
|
+
}, [onMessage]);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
connect();
|
|
46
|
+
return () => {
|
|
47
|
+
wsRef.current?.close();
|
|
48
|
+
};
|
|
49
|
+
}, [connect]);
|
|
50
|
+
|
|
51
|
+
const send = useCallback((msg: ClientMessage) => {
|
|
52
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
53
|
+
wsRef.current.send(JSON.stringify(msg));
|
|
54
|
+
}
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
return { send, connected };
|
|
58
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface AgentMeta {
|
|
2
|
+
id: string;
|
|
3
|
+
emoji: string;
|
|
4
|
+
name: string;
|
|
5
|
+
role: string;
|
|
6
|
+
workingColor: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const AGENTS: AgentMeta[] = [
|
|
10
|
+
{ id: "alex", emoji: "🦁", name: "Alex", role: "PM", workingColor: "#8b5cf6" },
|
|
11
|
+
{ id: "mina", emoji: "🦉", name: "Mina", role: "기획", workingColor: "#8b5cf6" },
|
|
12
|
+
{ id: "lena", emoji: "🦋", name: "Lena", role: "디자인", workingColor: "#34d399" },
|
|
13
|
+
{ id: "jay", emoji: "🦊", name: "Jay", role: "FE", workingColor: "#34d399" },
|
|
14
|
+
{ id: "sam", emoji: "🐻", name: "Sam", role: "BE", workingColor: "#34d399" },
|
|
15
|
+
{ id: "dex", emoji: "🐺", name: "Dex", role: "Infra", workingColor: "#34d399" },
|
|
16
|
+
{ id: "eva", emoji: "🐱", name: "Eva", role: "QA", workingColor: "#34d399" },
|
|
17
|
+
{ id: "rex", emoji: "🐍", name: "Rex", role: "보안", workingColor: "#8b5cf6" },
|
|
18
|
+
{ id: "nora", emoji: "🦅", name: "Nora", role: "리뷰", workingColor: "#34d399" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const AGENT_MAP = Object.fromEntries(AGENTS.map((a) => [a.id, a]));
|
|
22
|
+
|
|
23
|
+
export const PHASE_NAMES = ["인풋", "기획", "설계", "구현", "QA"] as const;
|
|
24
|
+
|
|
25
|
+
export const ACTIVITY_TAG: Record<string, { label: string; color: string }> = {
|
|
26
|
+
success: { label: "완료", color: "#34d399" },
|
|
27
|
+
progress: { label: "진행", color: "#a5b4fc" },
|
|
28
|
+
info: { label: "info", color: "#a5b4fc" },
|
|
29
|
+
error: { label: "에러", color: "#ef4444" },
|
|
30
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Activity, CheckpointInfo } from "@/types/pipeline";
|
|
2
|
+
|
|
3
|
+
export function detectCheckpoint(activities: Activity[]): CheckpointInfo | null {
|
|
4
|
+
let lastCheckpointIdx = -1;
|
|
5
|
+
let checkpointPhase = 0;
|
|
6
|
+
let checkpointDesc = "";
|
|
7
|
+
|
|
8
|
+
for (let i = activities.length - 1; i >= 0; i--) {
|
|
9
|
+
const a = activities[i];
|
|
10
|
+
if (a.agentId === "system" && a.message.includes("Checkpoint")) {
|
|
11
|
+
if (a.message.includes("approved") || a.message.includes("rejected")) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
lastCheckpointIdx = i;
|
|
15
|
+
checkpointDesc = a.message;
|
|
16
|
+
const phaseMatch = a.message.match(/Phase (\d+)/);
|
|
17
|
+
if (phaseMatch) checkpointPhase = parseInt(phaseMatch[1]);
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (lastCheckpointIdx === -1) return null;
|
|
23
|
+
|
|
24
|
+
for (let i = lastCheckpointIdx + 1; i < activities.length; i++) {
|
|
25
|
+
const a = activities[i];
|
|
26
|
+
if (a.agentId === "system") {
|
|
27
|
+
if (a.message.includes("Checkpoint approved")) return null;
|
|
28
|
+
if (a.message.includes("Checkpoint rejected")) return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
phase: checkpointPhase,
|
|
34
|
+
description: checkpointDesc,
|
|
35
|
+
status: "pending",
|
|
36
|
+
};
|
|
37
|
+
}
|