All checks were successful
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m19s
Build App / build-windows (push) Successful in 2m35s
Build App / build-linux (push) Successful in 4m43s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 11s
New projects default to standard permission mode (Claude asks before acting). Existing projects default to full permissions ON, preserving current behavior. UI toggle uses red/caution styling to highlight the security implications. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1144 lines
52 KiB
TypeScript
1144 lines
52 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { open } from "@tauri-apps/plugin-dialog";
|
|
import { listen } from "@tauri-apps/api/event";
|
|
import type { Project, ProjectPath, Backend, BedrockConfig, BedrockAuthMethod, OllamaConfig, OpenAiCompatibleConfig } from "../../lib/types";
|
|
import { useProjects } from "../../hooks/useProjects";
|
|
import { useMcpServers } from "../../hooks/useMcpServers";
|
|
import { useTerminal } from "../../hooks/useTerminal";
|
|
import { useAppState } from "../../store/appState";
|
|
import EnvVarsModal from "./EnvVarsModal";
|
|
import PortMappingsModal from "./PortMappingsModal";
|
|
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
|
import ContainerProgressModal from "./ContainerProgressModal";
|
|
import FileManagerModal from "./FileManagerModal";
|
|
import ConfirmRemoveModal from "./ConfirmRemoveModal";
|
|
import Tooltip from "../ui/Tooltip";
|
|
|
|
interface Props {
|
|
project: Project;
|
|
}
|
|
|
|
export default function ProjectCard({ project }: Props) {
|
|
const selectedProjectId = useAppState(s => s.selectedProjectId);
|
|
const setSelectedProject = useAppState(s => s.setSelectedProject);
|
|
const { start, stop, rebuild, remove, update } = useProjects();
|
|
const { mcpServers } = useMcpServers();
|
|
const { open: openTerminal } = useTerminal();
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showConfig, setShowConfig] = useState(false);
|
|
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
|
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
|
|
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
|
const [showFileManager, setShowFileManager] = useState(false);
|
|
const [progressMsg, setProgressMsg] = useState<string | null>(null);
|
|
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
|
const [operationCompleted, setOperationCompleted] = useState(false);
|
|
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
|
const [isEditingName, setIsEditingName] = useState(false);
|
|
const [editName, setEditName] = useState(project.name);
|
|
const isSelected = selectedProjectId === project.id;
|
|
const isStopped = project.status === "stopped" || project.status === "error";
|
|
|
|
// Local state for text fields (save on blur, not on every keystroke)
|
|
const [paths, setPaths] = useState<ProjectPath[]>(project.paths ?? []);
|
|
const [sshKeyPath, setSshKeyPath] = useState(project.ssh_key_path ?? "");
|
|
const [gitName, setGitName] = useState(project.git_user_name ?? "");
|
|
const [gitEmail, setGitEmail] = useState(project.git_user_email ?? "");
|
|
const [gitToken, setGitToken] = useState(project.git_token ?? "");
|
|
const [claudeInstructions, setClaudeInstructions] = useState(project.claude_instructions ?? "");
|
|
const [envVars, setEnvVars] = useState(project.custom_env_vars ?? []);
|
|
const [portMappings, setPortMappings] = useState(project.port_mappings ?? []);
|
|
|
|
// Bedrock local state for text fields
|
|
const [bedrockRegion, setBedrockRegion] = useState(project.bedrock_config?.aws_region ?? "us-east-1");
|
|
const [bedrockAccessKeyId, setBedrockAccessKeyId] = useState(project.bedrock_config?.aws_access_key_id ?? "");
|
|
const [bedrockSecretKey, setBedrockSecretKey] = useState(project.bedrock_config?.aws_secret_access_key ?? "");
|
|
const [bedrockSessionToken, setBedrockSessionToken] = useState(project.bedrock_config?.aws_session_token ?? "");
|
|
const [bedrockProfile, setBedrockProfile] = useState(project.bedrock_config?.aws_profile ?? "");
|
|
const [bedrockBearerToken, setBedrockBearerToken] = useState(project.bedrock_config?.aws_bearer_token ?? "");
|
|
const [bedrockModelId, setBedrockModelId] = useState(project.bedrock_config?.model_id ?? "");
|
|
|
|
// Ollama local state
|
|
const [ollamaBaseUrl, setOllamaBaseUrl] = useState(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
|
|
const [ollamaModelId, setOllamaModelId] = useState(project.ollama_config?.model_id ?? "");
|
|
|
|
// OpenAI Compatible local state
|
|
const [openaiCompatibleBaseUrl, setOpenaiCompatibleBaseUrl] = useState(project.openai_compatible_config?.base_url ?? "http://host.docker.internal:4000");
|
|
const [openaiCompatibleApiKey, setOpenaiCompatibleApiKey] = useState(project.openai_compatible_config?.api_key ?? "");
|
|
const [openaiCompatibleModelId, setOpenaiCompatibleModelId] = useState(project.openai_compatible_config?.model_id ?? "");
|
|
|
|
// Sync local state when project prop changes (e.g., after save or external update)
|
|
useEffect(() => {
|
|
setEditName(project.name);
|
|
setPaths(project.paths ?? []);
|
|
setSshKeyPath(project.ssh_key_path ?? "");
|
|
setGitName(project.git_user_name ?? "");
|
|
setGitEmail(project.git_user_email ?? "");
|
|
setGitToken(project.git_token ?? "");
|
|
setClaudeInstructions(project.claude_instructions ?? "");
|
|
setEnvVars(project.custom_env_vars ?? []);
|
|
setPortMappings(project.port_mappings ?? []);
|
|
setBedrockRegion(project.bedrock_config?.aws_region ?? "us-east-1");
|
|
setBedrockAccessKeyId(project.bedrock_config?.aws_access_key_id ?? "");
|
|
setBedrockSecretKey(project.bedrock_config?.aws_secret_access_key ?? "");
|
|
setBedrockSessionToken(project.bedrock_config?.aws_session_token ?? "");
|
|
setBedrockProfile(project.bedrock_config?.aws_profile ?? "");
|
|
setBedrockBearerToken(project.bedrock_config?.aws_bearer_token ?? "");
|
|
setBedrockModelId(project.bedrock_config?.model_id ?? "");
|
|
setOllamaBaseUrl(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
|
|
setOllamaModelId(project.ollama_config?.model_id ?? "");
|
|
setOpenaiCompatibleBaseUrl(project.openai_compatible_config?.base_url ?? "http://host.docker.internal:4000");
|
|
setOpenaiCompatibleApiKey(project.openai_compatible_config?.api_key ?? "");
|
|
setOpenaiCompatibleModelId(project.openai_compatible_config?.model_id ?? "");
|
|
}, [project]);
|
|
|
|
// Listen for container progress events
|
|
useEffect(() => {
|
|
const unlisten = listen<{ project_id: string; message: string }>(
|
|
"container-progress",
|
|
(event) => {
|
|
if (event.payload.project_id === project.id) {
|
|
setProgressMsg(event.payload.message);
|
|
}
|
|
}
|
|
);
|
|
return () => { unlisten.then((f) => f()); };
|
|
}, [project.id]);
|
|
|
|
// Mark operation completed when status settles
|
|
useEffect(() => {
|
|
if (project.status === "running" || project.status === "stopped" || project.status === "error") {
|
|
if (activeOperation) {
|
|
setOperationCompleted(true);
|
|
}
|
|
// Clear progress if no modal is managing it
|
|
if (!activeOperation) {
|
|
setProgressMsg(null);
|
|
}
|
|
}
|
|
}, [project.status, activeOperation]);
|
|
|
|
const handleStart = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
setProgressMsg(null);
|
|
setOperationCompleted(false);
|
|
setActiveOperation("starting");
|
|
try {
|
|
await start(project.id);
|
|
} catch (e) {
|
|
setError(String(e));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleStop = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
setProgressMsg(null);
|
|
setOperationCompleted(false);
|
|
setActiveOperation("stopping");
|
|
try {
|
|
await stop(project.id);
|
|
} catch (e) {
|
|
setError(String(e));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleOpenTerminal = async () => {
|
|
try {
|
|
await openTerminal(project.id, project.name);
|
|
} catch (e) {
|
|
setError(String(e));
|
|
}
|
|
};
|
|
|
|
const handleOpenBashShell = async () => {
|
|
try {
|
|
await openTerminal(project.id, project.name, "bash");
|
|
} catch (e) {
|
|
setError(String(e));
|
|
}
|
|
};
|
|
|
|
const handleForceStop = async () => {
|
|
try {
|
|
await stop(project.id);
|
|
} catch (e) {
|
|
setError(String(e));
|
|
}
|
|
};
|
|
|
|
const closeModal = () => {
|
|
setActiveOperation(null);
|
|
setOperationCompleted(false);
|
|
setProgressMsg(null);
|
|
setError(null);
|
|
};
|
|
|
|
const defaultBedrockConfig: BedrockConfig = {
|
|
auth_method: "static_credentials",
|
|
aws_region: "us-east-1",
|
|
aws_access_key_id: null,
|
|
aws_secret_access_key: null,
|
|
aws_session_token: null,
|
|
aws_profile: null,
|
|
aws_bearer_token: null,
|
|
model_id: null,
|
|
disable_prompt_caching: false,
|
|
};
|
|
|
|
const defaultOllamaConfig: OllamaConfig = {
|
|
base_url: "http://host.docker.internal:11434",
|
|
model_id: null,
|
|
};
|
|
|
|
const defaultOpenAiCompatibleConfig: OpenAiCompatibleConfig = {
|
|
base_url: "http://host.docker.internal:4000",
|
|
api_key: null,
|
|
model_id: null,
|
|
};
|
|
|
|
const handleBackendChange = async (mode: Backend) => {
|
|
try {
|
|
const updates: Partial<Project> = { backend: mode };
|
|
if (mode === "bedrock" && !project.bedrock_config) {
|
|
updates.bedrock_config = defaultBedrockConfig;
|
|
}
|
|
if (mode === "ollama" && !project.ollama_config) {
|
|
updates.ollama_config = defaultOllamaConfig;
|
|
}
|
|
if (mode === "open_ai_compatible" && !project.openai_compatible_config) {
|
|
updates.openai_compatible_config = defaultOpenAiCompatibleConfig;
|
|
}
|
|
await update({ ...project, ...updates });
|
|
} catch (e) {
|
|
setError(String(e));
|
|
}
|
|
};
|
|
|
|
const updateBedrockConfig = async (patch: Partial<BedrockConfig>) => {
|
|
try {
|
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
|
await update({ ...project, bedrock_config: { ...current, ...patch } });
|
|
} catch (err) {
|
|
console.error("Failed to update Bedrock config:", err);
|
|
}
|
|
};
|
|
|
|
const handleBrowseSSH = async () => {
|
|
const selected = await open({ directory: true, multiple: false });
|
|
if (selected) {
|
|
try {
|
|
await update({ ...project, ssh_key_path: selected as string });
|
|
} catch (e) {
|
|
setError(String(e));
|
|
}
|
|
}
|
|
};
|
|
|
|
// Blur handlers for text fields
|
|
const handleSshKeyPathBlur = async () => {
|
|
try {
|
|
await update({ ...project, ssh_key_path: sshKeyPath || null });
|
|
} catch (err) {
|
|
console.error("Failed to update SSH key path:", err);
|
|
}
|
|
};
|
|
|
|
const handleGitNameBlur = async () => {
|
|
try {
|
|
await update({ ...project, git_user_name: gitName || null });
|
|
} catch (err) {
|
|
console.error("Failed to update Git name:", err);
|
|
}
|
|
};
|
|
|
|
const handleGitEmailBlur = async () => {
|
|
try {
|
|
await update({ ...project, git_user_email: gitEmail || null });
|
|
} catch (err) {
|
|
console.error("Failed to update Git email:", err);
|
|
}
|
|
};
|
|
|
|
const handleGitTokenBlur = async () => {
|
|
try {
|
|
await update({ ...project, git_token: gitToken || null });
|
|
} catch (err) {
|
|
console.error("Failed to update Git token:", err);
|
|
}
|
|
};
|
|
|
|
const handleBedrockRegionBlur = async () => {
|
|
try {
|
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
|
await update({ ...project, bedrock_config: { ...current, aws_region: bedrockRegion } });
|
|
} catch (err) {
|
|
console.error("Failed to update Bedrock region:", err);
|
|
}
|
|
};
|
|
|
|
const handleBedrockAccessKeyIdBlur = async () => {
|
|
try {
|
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
|
await update({ ...project, bedrock_config: { ...current, aws_access_key_id: bedrockAccessKeyId || null } });
|
|
} catch (err) {
|
|
console.error("Failed to update Bedrock access key:", err);
|
|
}
|
|
};
|
|
|
|
const handleBedrockSecretKeyBlur = async () => {
|
|
try {
|
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
|
await update({ ...project, bedrock_config: { ...current, aws_secret_access_key: bedrockSecretKey || null } });
|
|
} catch (err) {
|
|
console.error("Failed to update Bedrock secret key:", err);
|
|
}
|
|
};
|
|
|
|
const handleBedrockSessionTokenBlur = async () => {
|
|
try {
|
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
|
await update({ ...project, bedrock_config: { ...current, aws_session_token: bedrockSessionToken || null } });
|
|
} catch (err) {
|
|
console.error("Failed to update Bedrock session token:", err);
|
|
}
|
|
};
|
|
|
|
const handleBedrockProfileBlur = async () => {
|
|
try {
|
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
|
await update({ ...project, bedrock_config: { ...current, aws_profile: bedrockProfile || null } });
|
|
} catch (err) {
|
|
console.error("Failed to update Bedrock profile:", err);
|
|
}
|
|
};
|
|
|
|
const handleBedrockBearerTokenBlur = async () => {
|
|
try {
|
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
|
await update({ ...project, bedrock_config: { ...current, aws_bearer_token: bedrockBearerToken || null } });
|
|
} catch (err) {
|
|
console.error("Failed to update Bedrock bearer token:", err);
|
|
}
|
|
};
|
|
|
|
const handleBedrockModelIdBlur = async () => {
|
|
try {
|
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
|
await update({ ...project, bedrock_config: { ...current, model_id: bedrockModelId || null } });
|
|
} catch (err) {
|
|
console.error("Failed to update Bedrock model ID:", err);
|
|
}
|
|
};
|
|
|
|
const handleOllamaBaseUrlBlur = async () => {
|
|
try {
|
|
const current = project.ollama_config ?? defaultOllamaConfig;
|
|
await update({ ...project, ollama_config: { ...current, base_url: ollamaBaseUrl } });
|
|
} catch (err) {
|
|
console.error("Failed to update Ollama base URL:", err);
|
|
}
|
|
};
|
|
|
|
const handleOllamaModelIdBlur = async () => {
|
|
try {
|
|
const current = project.ollama_config ?? defaultOllamaConfig;
|
|
await update({ ...project, ollama_config: { ...current, model_id: ollamaModelId || null } });
|
|
} catch (err) {
|
|
console.error("Failed to update Ollama model ID:", err);
|
|
}
|
|
};
|
|
|
|
const handleOpenaiCompatibleBaseUrlBlur = async () => {
|
|
try {
|
|
const current = project.openai_compatible_config ?? defaultOpenAiCompatibleConfig;
|
|
await update({ ...project, openai_compatible_config: { ...current, base_url: openaiCompatibleBaseUrl } });
|
|
} catch (err) {
|
|
console.error("Failed to update OpenAI Compatible base URL:", err);
|
|
}
|
|
};
|
|
|
|
const handleOpenaiCompatibleApiKeyBlur = async () => {
|
|
try {
|
|
const current = project.openai_compatible_config ?? defaultOpenAiCompatibleConfig;
|
|
await update({ ...project, openai_compatible_config: { ...current, api_key: openaiCompatibleApiKey || null } });
|
|
} catch (err) {
|
|
console.error("Failed to update OpenAI Compatible API key:", err);
|
|
}
|
|
};
|
|
|
|
const handleOpenaiCompatibleModelIdBlur = async () => {
|
|
try {
|
|
const current = project.openai_compatible_config ?? defaultOpenAiCompatibleConfig;
|
|
await update({ ...project, openai_compatible_config: { ...current, model_id: openaiCompatibleModelId || null } });
|
|
} catch (err) {
|
|
console.error("Failed to update OpenAI Compatible model ID:", err);
|
|
}
|
|
};
|
|
|
|
const statusColor = {
|
|
stopped: "bg-[var(--text-secondary)]",
|
|
starting: "bg-[var(--warning)]",
|
|
running: "bg-[var(--success)]",
|
|
stopping: "bg-[var(--warning)]",
|
|
error: "bg-[var(--error)]",
|
|
}[project.status];
|
|
|
|
return (
|
|
<div
|
|
onClick={() => setSelectedProject(project.id)}
|
|
className={`px-3 py-2 rounded cursor-pointer transition-colors min-w-0 overflow-hidden ${
|
|
isSelected
|
|
? "bg-[var(--bg-tertiary)]"
|
|
: "hover:bg-[var(--bg-tertiary)]"
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${statusColor}`} />
|
|
{isEditingName ? (
|
|
<input
|
|
autoFocus
|
|
value={editName}
|
|
onChange={(e) => setEditName(e.target.value)}
|
|
onBlur={async () => {
|
|
setIsEditingName(false);
|
|
const trimmed = editName.trim();
|
|
if (trimmed && trimmed !== project.name) {
|
|
try {
|
|
await update({ ...project, name: trimmed });
|
|
} catch (err) {
|
|
console.error("Failed to rename project:", err);
|
|
setEditName(project.name);
|
|
}
|
|
} else {
|
|
setEditName(project.name);
|
|
}
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
|
if (e.key === "Escape") { setEditName(project.name); setIsEditingName(false); }
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="text-sm font-medium flex-1 min-w-0 px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded text-[var(--text-primary)] focus:outline-none"
|
|
/>
|
|
) : (
|
|
<span
|
|
className="text-sm font-medium truncate flex-1 cursor-text"
|
|
title="Double-click to rename"
|
|
onDoubleClick={(e) => { e.stopPropagation(); setIsEditingName(true); }}
|
|
>
|
|
{project.name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-0.5 ml-4 space-y-0.5">
|
|
{project.paths.map((pp, i) => (
|
|
<div key={i} className="text-xs text-[var(--text-secondary)] truncate">
|
|
<span className="font-mono">/workspace/{pp.mount_name}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{isSelected && (
|
|
<div className="mt-2 ml-4 space-y-2 min-w-0 overflow-hidden">
|
|
{/* Backend selector */}
|
|
<div className="flex items-center gap-1 text-xs">
|
|
<span className="text-[var(--text-secondary)] mr-1">Backend:<Tooltip text="Choose the AI model provider for this project. Anthropic: Connect directly to Claude via OAuth login (run 'claude login' in terminal). Bedrock: Route through AWS Bedrock using your AWS credentials. Ollama: Use locally-hosted open-source models (Llama, Mistral, etc.) via an Ollama server. OpenAI Compatible: Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.) to access 100+ model providers." /></span>
|
|
<select
|
|
value={project.backend}
|
|
onChange={(e) => { e.stopPropagation(); handleBackendChange(e.target.value as Backend); }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
disabled={!isStopped}
|
|
className="px-2 py-0.5 rounded bg-[var(--bg-primary)] border border-[var(--border-color)] text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
|
>
|
|
<option value="anthropic">Anthropic</option>
|
|
<option value="bedrock">Bedrock</option>
|
|
<option value="ollama">Ollama</option>
|
|
<option value="open_ai_compatible">OpenAI Compatible</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex items-center gap-1 flex-wrap">
|
|
{isStopped ? (
|
|
<>
|
|
<ActionButton onClick={handleStart} disabled={loading} label="Start" />
|
|
<ActionButton
|
|
onClick={async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
setProgressMsg(null);
|
|
setOperationCompleted(false);
|
|
setActiveOperation("resetting");
|
|
try { await rebuild(project.id); } catch (e) { setError(String(e)); }
|
|
setLoading(false);
|
|
}}
|
|
disabled={loading}
|
|
label="Reset"
|
|
/>
|
|
</>
|
|
) : project.status === "running" ? (
|
|
<>
|
|
<ActionButton onClick={handleStop} disabled={loading} label="Stop" />
|
|
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
|
<ActionButton onClick={handleOpenBashShell} disabled={loading} label="Shell" />
|
|
<ActionButton onClick={() => setShowFileManager(true)} disabled={loading} label="Files" />
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="text-xs text-[var(--text-secondary)]">
|
|
{progressMsg ?? `${project.status}...`}
|
|
</span>
|
|
<ActionButton onClick={handleStop} disabled={loading} label="Force Stop" danger />
|
|
</>
|
|
)}
|
|
<ActionButton
|
|
onClick={(e) => { e?.stopPropagation?.(); setShowConfig(!showConfig); }}
|
|
disabled={false}
|
|
label={showConfig ? "Hide" : "Config"}
|
|
/>
|
|
<ActionButton
|
|
onClick={() => setShowRemoveModal(true)}
|
|
disabled={loading}
|
|
label="Remove"
|
|
danger
|
|
/>
|
|
</div>
|
|
|
|
{/* Config panel */}
|
|
{showConfig && (
|
|
<div className="space-y-2 pt-1 border-t border-[var(--border-color)] min-w-0 overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
|
{!isStopped && (
|
|
<div className="px-2 py-1.5 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
|
Container must be stopped to change settings.
|
|
</div>
|
|
)}
|
|
{/* Folder paths */}
|
|
<div>
|
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Folders</label>
|
|
{paths.map((pp, i) => (
|
|
<div key={i} className="mb-1">
|
|
<div className="flex gap-1 items-center min-w-0">
|
|
<input
|
|
value={pp.host_path}
|
|
onChange={(e) => {
|
|
const updated = [...paths];
|
|
updated[i] = { ...updated[i], host_path: e.target.value };
|
|
setPaths(updated);
|
|
}}
|
|
onBlur={async () => {
|
|
try { await update({ ...project, paths }); } catch (err) {
|
|
console.error("Failed to update paths:", err);
|
|
}
|
|
}}
|
|
placeholder="/path/to/folder"
|
|
disabled={!isStopped}
|
|
className="flex-1 min-w-0 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
|
/>
|
|
<button
|
|
onClick={async () => {
|
|
const selected = await open({ directory: true, multiple: false });
|
|
if (typeof selected === "string") {
|
|
const updated = [...paths];
|
|
const basename = selected.replace(/[/\\]$/, "").split(/[/\\]/).pop() || "";
|
|
updated[i] = { host_path: selected, mount_name: updated[i].mount_name || basename };
|
|
setPaths(updated);
|
|
try { await update({ ...project, paths: updated }); } catch (err) {
|
|
console.error("Failed to update paths:", err);
|
|
}
|
|
}
|
|
}}
|
|
disabled={!isStopped}
|
|
className="flex-shrink-0 px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:opacity-50 transition-colors"
|
|
>
|
|
...
|
|
</button>
|
|
{paths.length > 1 && (
|
|
<button
|
|
onClick={async () => {
|
|
const updated = paths.filter((_, j) => j !== i);
|
|
setPaths(updated);
|
|
try { await update({ ...project, paths: updated }); } catch (err) {
|
|
console.error("Failed to remove path:", err);
|
|
}
|
|
}}
|
|
disabled={!isStopped}
|
|
className="flex-shrink-0 px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
|
|
>
|
|
x
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-1 items-center mt-0.5 min-w-0">
|
|
<span className="text-xs text-[var(--text-secondary)]">/workspace/</span>
|
|
<input
|
|
value={pp.mount_name}
|
|
onChange={(e) => {
|
|
const updated = [...paths];
|
|
updated[i] = { ...updated[i], mount_name: e.target.value };
|
|
setPaths(updated);
|
|
}}
|
|
onBlur={async () => {
|
|
try { await update({ ...project, paths }); } catch (err) {
|
|
console.error("Failed to update paths:", err);
|
|
}
|
|
}}
|
|
placeholder="name"
|
|
disabled={!isStopped}
|
|
className="flex-1 min-w-0 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<button
|
|
onClick={async () => {
|
|
const updated = [...paths, { host_path: "", mount_name: "" }];
|
|
setPaths(updated);
|
|
}}
|
|
disabled={!isStopped}
|
|
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
|
>
|
|
+ Add folder
|
|
</button>
|
|
</div>
|
|
|
|
{/* SSH Key */}
|
|
<div>
|
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory<Tooltip text="Path to your .ssh directory. Mounted into the container so Claude can authenticate with Git remotes over SSH." /></label>
|
|
<div className="flex gap-1">
|
|
<input
|
|
value={sshKeyPath}
|
|
onChange={(e) => setSshKeyPath(e.target.value)}
|
|
onBlur={handleSshKeyPathBlur}
|
|
placeholder="~/.ssh"
|
|
disabled={!isStopped}
|
|
className="flex-1 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
|
/>
|
|
<button
|
|
onClick={handleBrowseSSH}
|
|
disabled={!isStopped}
|
|
className="px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:opacity-50 transition-colors"
|
|
>
|
|
...
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Git Name */}
|
|
<div>
|
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name<Tooltip text="Sets git user.name inside the container for commit authorship." /></label>
|
|
<input
|
|
value={gitName}
|
|
onChange={(e) => setGitName(e.target.value)}
|
|
onBlur={handleGitNameBlur}
|
|
placeholder="Your Name"
|
|
disabled={!isStopped}
|
|
className="w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
|
/>
|
|
</div>
|
|
|
|
{/* Git Email */}
|
|
<div>
|
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email<Tooltip text="Sets git user.email inside the container for commit authorship." /></label>
|
|
<input
|
|
value={gitEmail}
|
|
onChange={(e) => setGitEmail(e.target.value)}
|
|
onBlur={handleGitEmailBlur}
|
|
placeholder="you@example.com"
|
|
disabled={!isStopped}
|
|
className="w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
|
/>
|
|
</div>
|
|
|
|
{/* Git Token (HTTPS) */}
|
|
<div>
|
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token<Tooltip text="A personal access token (e.g. GitHub PAT) for HTTPS git operations inside the container." /></label>
|
|
<input
|
|
type="password"
|
|
value={gitToken}
|
|
onChange={(e) => setGitToken(e.target.value)}
|
|
onBlur={handleGitTokenBlur}
|
|
placeholder="ghp_..."
|
|
disabled={!isStopped}
|
|
className="w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
|
/>
|
|
</div>
|
|
|
|
{/* Docker access toggle */}
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-xs text-[var(--text-secondary)]">Allow container spawning<Tooltip text="Mounts the Docker socket so Claude can build and run Docker containers from inside the sandbox." /></label>
|
|
<button
|
|
onClick={async () => {
|
|
try { await update({ ...project, allow_docker_access: !project.allow_docker_access }); } catch (err) {
|
|
console.error("Failed to update Docker access setting:", err);
|
|
}
|
|
}}
|
|
disabled={!isStopped}
|
|
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
|
|
project.allow_docker_access
|
|
? "bg-[var(--success)] text-white"
|
|
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
|
}`}
|
|
>
|
|
{project.allow_docker_access ? "ON" : "OFF"}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Mission Control toggle */}
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-xs text-[var(--text-secondary)]">Mission Control<Tooltip text="Enables a web dashboard for monitoring and managing Claude sessions remotely." /></label>
|
|
<button
|
|
onClick={async () => {
|
|
try {
|
|
await update({ ...project, mission_control_enabled: !project.mission_control_enabled });
|
|
} catch (err) {
|
|
console.error("Failed to update Mission Control setting:", err);
|
|
}
|
|
}}
|
|
disabled={!isStopped}
|
|
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
|
|
project.mission_control_enabled
|
|
? "bg-[var(--success)] text-white"
|
|
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
|
}`}
|
|
>
|
|
{project.mission_control_enabled ? "ON" : "OFF"}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Full Permissions toggle */}
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-xs text-[var(--text-secondary)]">
|
|
Full Permissions
|
|
<span className="text-[var(--error)] font-semibold ml-1">(CAUTION)</span>
|
|
<Tooltip text="When enabled, Claude runs with --dangerously-skip-permissions and auto-approves all tool calls without prompting. Only enable this if you trust the sandboxed environment to contain all actions. When disabled, Claude will ask for your approval before running commands, editing files, etc." />
|
|
</label>
|
|
<button
|
|
onClick={async () => {
|
|
try {
|
|
await update({ ...project, full_permissions: !project.full_permissions });
|
|
} catch (err) {
|
|
console.error("Failed to update full permissions setting:", err);
|
|
}
|
|
}}
|
|
disabled={!isStopped}
|
|
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
|
|
project.full_permissions
|
|
? "bg-[var(--error)] text-white"
|
|
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
|
}`}
|
|
>
|
|
{project.full_permissions ? "ON" : "OFF"}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Environment Variables */}
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs text-[var(--text-secondary)]">
|
|
Environment Variables{envVars.length > 0 && ` (${envVars.length})`}<Tooltip text="Custom env vars injected into this project's container. Useful for API keys or tool configuration." />
|
|
</label>
|
|
<button
|
|
onClick={() => setShowEnvVarsModal(true)}
|
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
|
>
|
|
Edit
|
|
</button>
|
|
</div>
|
|
|
|
{/* Port Mappings */}
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs text-[var(--text-secondary)]">
|
|
Port Mappings{portMappings.length > 0 && ` (${portMappings.length})`}<Tooltip text="Map container ports to host ports so you can access dev servers running inside the container." />
|
|
</label>
|
|
<button
|
|
onClick={() => setShowPortMappingsModal(true)}
|
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
|
>
|
|
Edit
|
|
</button>
|
|
</div>
|
|
|
|
{/* Claude Instructions */}
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs text-[var(--text-secondary)]">
|
|
Claude Instructions{claudeInstructions ? " (set)" : ""}<Tooltip text="Project-specific instructions written to CLAUDE.md. Guides Claude's behavior for this project." />
|
|
</label>
|
|
<button
|
|
onClick={() => setShowClaudeInstructionsModal(true)}
|
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
|
>
|
|
Edit
|
|
</button>
|
|
</div>
|
|
|
|
{/* MCP Servers */}
|
|
{mcpServers.length > 0 && (
|
|
<div>
|
|
<label className="block text-xs text-[var(--text-secondary)] mb-1">MCP Servers<Tooltip text="Model Context Protocol servers give Claude access to external tools and data sources." /></label>
|
|
<div className="space-y-1">
|
|
{mcpServers.map((server) => {
|
|
const enabled = project.enabled_mcp_servers.includes(server.id);
|
|
const isDocker = !!server.docker_image;
|
|
return (
|
|
<label key={server.id} className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={enabled}
|
|
disabled={!isStopped}
|
|
onChange={async () => {
|
|
const updated = enabled
|
|
? project.enabled_mcp_servers.filter((id) => id !== server.id)
|
|
: [...project.enabled_mcp_servers, server.id];
|
|
try {
|
|
await update({ ...project, enabled_mcp_servers: updated });
|
|
} catch (err) {
|
|
console.error("Failed to update MCP servers:", err);
|
|
}
|
|
}}
|
|
className="rounded border-[var(--border-color)] disabled:opacity-50"
|
|
/>
|
|
<span className="text-xs text-[var(--text-primary)]">{server.name}</span>
|
|
<span className="text-xs text-[var(--text-secondary)]">({server.transport_type})</span>
|
|
<span className={`text-xs px-1 py-0.5 rounded ${isDocker ? "bg-blue-500/20 text-blue-400" : "bg-[var(--bg-secondary)] text-[var(--text-secondary)]"}`}>
|
|
{isDocker ? "Docker" : "Manual"}
|
|
</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
{mcpServers.some((s) => s.docker_image && s.transport_type === "stdio" && project.enabled_mcp_servers.includes(s.id)) && (
|
|
<p className="text-xs text-[var(--text-secondary)] mt-1 opacity-70">
|
|
Docker access will be auto-enabled for stdio+Docker MCP servers.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Bedrock config */}
|
|
{project.backend === "bedrock" && (() => {
|
|
const bc = project.bedrock_config ?? defaultBedrockConfig;
|
|
const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50";
|
|
return (
|
|
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]">
|
|
<label className="block text-xs font-medium text-[var(--text-primary)]">AWS Bedrock</label>
|
|
|
|
{/* Sub-method selector */}
|
|
<div className="flex items-center gap-1 text-xs">
|
|
<span className="text-[var(--text-secondary)] mr-1">Method:</span>
|
|
<select
|
|
value={bc.auth_method}
|
|
onChange={(e) => updateBedrockConfig({ auth_method: e.target.value as BedrockAuthMethod })}
|
|
onClick={(e) => e.stopPropagation()}
|
|
disabled={!isStopped}
|
|
className="px-2 py-0.5 rounded bg-[var(--bg-primary)] border border-[var(--border-color)] text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
|
>
|
|
<option value="static_credentials">Keys</option>
|
|
<option value="profile">Profile</option>
|
|
<option value="bearer_token">Token</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* AWS Region (always shown) */}
|
|
<div>
|
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region<Tooltip text="The AWS region where your Bedrock endpoint is available (e.g. us-east-1)." /></label>
|
|
<input
|
|
value={bedrockRegion}
|
|
onChange={(e) => setBedrockRegion(e.target.value)}
|
|
onBlur={handleBedrockRegionBlur}
|
|
placeholder="us-east-1"
|
|
disabled={!isStopped}
|
|
className={inputCls}
|
|
/>
|
|
</div>
|
|
|
|
{/* Static credentials fields */}
|
|
{bc.auth_method === "static_credentials" && (
|
|
<>
|
|
<div>
|
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID<Tooltip text="Your AWS IAM access key ID for Bedrock API authentication." /></label>
|
|
<input
|
|
value={bedrockAccessKeyId}
|
|
onChange={(e) => setBedrockAccessKeyId(e.target.value)}
|
|
onBlur={handleBedrockAccessKeyIdBlur}
|
|
placeholder="AKIA..."
|
|
disabled={!isStopped}
|
|
className={inputCls}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key<Tooltip text="Your AWS IAM secret key. Stored locally and injected as an env var into the container." /></label>
|
|
<input
|
|
type="password"
|
|
value={bedrockSecretKey}
|
|
onChange={(e) => setBedrockSecretKey(e.target.value)}
|
|
onBlur={handleBedrockSecretKeyBlur}
|
|
disabled={!isStopped}
|
|
className={inputCls}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)<Tooltip text="Temporary session token for assumed-role or MFA-based AWS credentials." /></label>
|
|
<input
|
|
type="password"
|
|
value={bedrockSessionToken}
|
|
onChange={(e) => setBedrockSessionToken(e.target.value)}
|
|
onBlur={handleBedrockSessionTokenBlur}
|
|
disabled={!isStopped}
|
|
className={inputCls}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Profile field */}
|
|
{bc.auth_method === "profile" && (
|
|
<div>
|
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile<Tooltip text="Named profile from your AWS config/credentials files (e.g. 'default' or 'prod')." /></label>
|
|
<input
|
|
value={bedrockProfile}
|
|
onChange={(e) => setBedrockProfile(e.target.value)}
|
|
onBlur={handleBedrockProfileBlur}
|
|
placeholder="default"
|
|
disabled={!isStopped}
|
|
className={inputCls}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Bearer token field */}
|
|
{bc.auth_method === "bearer_token" && (
|
|
<div>
|
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token<Tooltip text="An SSO or identity-center bearer token for Bedrock authentication." /></label>
|
|
<input
|
|
type="password"
|
|
value={bedrockBearerToken}
|
|
onChange={(e) => setBedrockBearerToken(e.target.value)}
|
|
onBlur={handleBedrockBearerTokenBlur}
|
|
disabled={!isStopped}
|
|
className={inputCls}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Model override */}
|
|
<div>
|
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)<Tooltip text="Override the default Bedrock model. Leave blank to use Claude's default." /></label>
|
|
<input
|
|
value={bedrockModelId}
|
|
onChange={(e) => setBedrockModelId(e.target.value)}
|
|
onBlur={handleBedrockModelIdBlur}
|
|
placeholder="anthropic.claude-sonnet-4-20250514-v1:0"
|
|
disabled={!isStopped}
|
|
className={inputCls}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* Ollama config */}
|
|
{project.backend === "ollama" && (() => {
|
|
const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50";
|
|
return (
|
|
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]">
|
|
<label className="block text-xs font-medium text-[var(--text-primary)]">Ollama</label>
|
|
<p className="text-xs text-[var(--text-secondary)]">
|
|
Connect to an Ollama server running locally or on a remote host.
|
|
</p>
|
|
|
|
<div>
|
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Base URL<Tooltip text="URL of your Ollama server. Use host.docker.internal to reach the host machine from inside the container." /></label>
|
|
<input
|
|
value={ollamaBaseUrl}
|
|
onChange={(e) => setOllamaBaseUrl(e.target.value)}
|
|
onBlur={handleOllamaBaseUrlBlur}
|
|
placeholder="http://host.docker.internal:11434"
|
|
disabled={!isStopped}
|
|
className={inputCls}
|
|
/>
|
|
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-70">
|
|
Use host.docker.internal for the host machine, or an IP/hostname for remote.
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model (required)<Tooltip text="Ollama model name to use (e.g. qwen3.5:27b). The model must be pulled in Ollama before starting the container." /></label>
|
|
<input
|
|
value={ollamaModelId}
|
|
onChange={(e) => setOllamaModelId(e.target.value)}
|
|
onBlur={handleOllamaModelIdBlur}
|
|
placeholder="qwen3.5:27b"
|
|
disabled={!isStopped}
|
|
className={inputCls}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* OpenAI Compatible config */}
|
|
{project.backend === "open_ai_compatible" && (() => {
|
|
const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50";
|
|
return (
|
|
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]">
|
|
<label className="block text-xs font-medium text-[var(--text-primary)]">OpenAI Compatible Endpoint</label>
|
|
<p className="text-xs text-[var(--text-secondary)]">
|
|
Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.).
|
|
</p>
|
|
|
|
<div>
|
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Base URL<Tooltip text="URL of your OpenAI API-compatible server. Use host.docker.internal for a locally running service." /></label>
|
|
<input
|
|
value={openaiCompatibleBaseUrl}
|
|
onChange={(e) => setOpenaiCompatibleBaseUrl(e.target.value)}
|
|
onBlur={handleOpenaiCompatibleBaseUrlBlur}
|
|
placeholder="http://host.docker.internal:4000"
|
|
disabled={!isStopped}
|
|
className={inputCls}
|
|
/>
|
|
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-70">
|
|
Use host.docker.internal for local, or a URL for a remote OpenAI-compatible service.
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">API Key<Tooltip text="Authentication key for your OpenAI-compatible endpoint, if required." /></label>
|
|
<input
|
|
type="password"
|
|
value={openaiCompatibleApiKey}
|
|
onChange={(e) => setOpenaiCompatibleApiKey(e.target.value)}
|
|
onBlur={handleOpenaiCompatibleApiKeyBlur}
|
|
placeholder="sk-..."
|
|
disabled={!isStopped}
|
|
className={inputCls}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model (optional)<Tooltip text="Model identifier as configured in your provider (e.g. gpt-4o, gemini-pro)." /></label>
|
|
<input
|
|
value={openaiCompatibleModelId}
|
|
onChange={(e) => setOpenaiCompatibleModelId(e.target.value)}
|
|
onBlur={handleOpenaiCompatibleModelIdBlur}
|
|
placeholder="gpt-4o / gemini-pro / etc."
|
|
disabled={!isStopped}
|
|
className={inputCls}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="text-xs text-[var(--error)] mt-1 ml-4">{error}</div>
|
|
)}
|
|
|
|
{showEnvVarsModal && (
|
|
<EnvVarsModal
|
|
envVars={envVars}
|
|
disabled={!isStopped}
|
|
onSave={async (vars) => {
|
|
setEnvVars(vars);
|
|
await update({ ...project, custom_env_vars: vars });
|
|
}}
|
|
onClose={() => setShowEnvVarsModal(false)}
|
|
/>
|
|
)}
|
|
|
|
{showPortMappingsModal && (
|
|
<PortMappingsModal
|
|
portMappings={portMappings}
|
|
disabled={!isStopped}
|
|
onSave={async (mappings) => {
|
|
setPortMappings(mappings);
|
|
await update({ ...project, port_mappings: mappings });
|
|
}}
|
|
onClose={() => setShowPortMappingsModal(false)}
|
|
/>
|
|
)}
|
|
|
|
{showClaudeInstructionsModal && (
|
|
<ClaudeInstructionsModal
|
|
instructions={claudeInstructions}
|
|
disabled={!isStopped}
|
|
onSave={async (instructions) => {
|
|
setClaudeInstructions(instructions);
|
|
await update({ ...project, claude_instructions: instructions || null });
|
|
}}
|
|
onClose={() => setShowClaudeInstructionsModal(false)}
|
|
/>
|
|
)}
|
|
|
|
{showFileManager && (
|
|
<FileManagerModal
|
|
projectId={project.id}
|
|
projectName={project.name}
|
|
onClose={() => setShowFileManager(false)}
|
|
/>
|
|
)}
|
|
|
|
{showRemoveModal && (
|
|
<ConfirmRemoveModal
|
|
projectName={project.name}
|
|
onConfirm={async () => {
|
|
setShowRemoveModal(false);
|
|
await remove(project.id);
|
|
}}
|
|
onCancel={() => setShowRemoveModal(false)}
|
|
/>
|
|
)}
|
|
|
|
{activeOperation && (
|
|
<ContainerProgressModal
|
|
projectName={project.name}
|
|
operation={activeOperation}
|
|
progressMsg={progressMsg}
|
|
error={error}
|
|
completed={operationCompleted}
|
|
onForceStop={handleForceStop}
|
|
onClose={closeModal}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActionButton({
|
|
onClick,
|
|
disabled,
|
|
label,
|
|
accent,
|
|
danger,
|
|
}: {
|
|
onClick: (e?: React.MouseEvent) => void;
|
|
disabled: boolean;
|
|
label: string;
|
|
accent?: boolean;
|
|
danger?: boolean;
|
|
}) {
|
|
let color = "text-[var(--text-secondary)] hover:text-[var(--text-primary)]";
|
|
if (accent) color = "text-[var(--accent)] hover:text-[var(--accent-hover)]";
|
|
if (danger) color = "text-[var(--error)] hover:text-[var(--error)]";
|
|
|
|
return (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onClick(e); }}
|
|
disabled={disabled}
|
|
className={`text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-50 ${color} hover:bg-[var(--bg-primary)]`}
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|