Replace the three auth modes (Login, API Key, Bedrock) with two (Anthropic, Bedrock). The Anthropic mode uses OAuth via `claude login` inside the terminal, which generates and stores its own API key in the persistent config volume. The separate API Key mode is removed because Claude Code now requires interactive approval of externally-provided keys, making the injected ANTHROPIC_API_KEY approach unreliable. Old projects stored as "login" or "api_key" are automatically migrated to "anthropic" via serde aliases. Also fix the Windows taskbar icon showing as a black square by loading icon.png instead of icon.ico for the runtime window icon. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
727 lines
31 KiB
TypeScript
727 lines
31 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { open } from "@tauri-apps/plugin-dialog";
|
|
import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types";
|
|
import { useProjects } from "../../hooks/useProjects";
|
|
import { useTerminal } from "../../hooks/useTerminal";
|
|
import { useAppState } from "../../store/appState";
|
|
import EnvVarsModal from "./EnvVarsModal";
|
|
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
|
|
|
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 { 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 [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
|
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 ?? []);
|
|
|
|
// 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 ?? "");
|
|
|
|
// Sync local state when project prop changes (e.g., after save or external update)
|
|
useEffect(() => {
|
|
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 ?? []);
|
|
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 ?? "");
|
|
}, [project]);
|
|
|
|
const handleStart = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
await start(project.id);
|
|
} catch (e) {
|
|
setError(String(e));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleStop = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
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 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 handleAuthModeChange = async (mode: AuthMode) => {
|
|
try {
|
|
const updates: Partial<Project> = { auth_mode: mode };
|
|
if (mode === "bedrock" && !project.bedrock_config) {
|
|
updates.bedrock_config = defaultBedrockConfig;
|
|
}
|
|
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 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}`} />
|
|
<span className="text-sm font-medium truncate flex-1">{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">
|
|
{/* Auth mode selector */}
|
|
<div className="flex items-center gap-1 text-xs">
|
|
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("anthropic"); }}
|
|
disabled={!isStopped}
|
|
className={`px-2 py-0.5 rounded transition-colors ${
|
|
project.auth_mode === "anthropic"
|
|
? "bg-[var(--accent)] text-white"
|
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
|
} disabled:opacity-50`}
|
|
>
|
|
Anthropic
|
|
</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("bedrock"); }}
|
|
disabled={!isStopped}
|
|
className={`px-2 py-0.5 rounded transition-colors ${
|
|
project.auth_mode === "bedrock"
|
|
? "bg-[var(--accent)] text-white"
|
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
|
} disabled:opacity-50`}
|
|
>
|
|
Bedrock
|
|
</button>
|
|
</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);
|
|
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 />
|
|
</>
|
|
) : (
|
|
<span className="text-xs text-[var(--text-secondary)]">
|
|
{project.status}...
|
|
</span>
|
|
)}
|
|
<ActionButton
|
|
onClick={(e) => { e?.stopPropagation?.(); setShowConfig(!showConfig); }}
|
|
disabled={false}
|
|
label={showConfig ? "Hide" : "Config"}
|
|
/>
|
|
<ActionButton
|
|
onClick={async () => {
|
|
if (confirm(`Remove project "${project.name}"?`)) {
|
|
await remove(project.id);
|
|
}
|
|
}}
|
|
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</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</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</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</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</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>
|
|
|
|
{/* Environment Variables */}
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs text-[var(--text-secondary)]">
|
|
Environment Variables{envVars.length > 0 && ` (${envVars.length})`}
|
|
</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>
|
|
|
|
{/* Claude Instructions */}
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs text-[var(--text-secondary)]">
|
|
Claude Instructions{claudeInstructions ? " (set)" : ""}
|
|
</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>
|
|
|
|
{/* Bedrock config */}
|
|
{project.auth_mode === "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>
|
|
{(["static_credentials", "profile", "bearer_token"] as BedrockAuthMethod[]).map((m) => (
|
|
<button
|
|
key={m}
|
|
onClick={() => updateBedrockConfig({ auth_method: m })}
|
|
disabled={!isStopped}
|
|
className={`px-2 py-0.5 rounded transition-colors ${
|
|
bc.auth_method === m
|
|
? "bg-[var(--accent)] text-white"
|
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
|
} disabled:opacity-50`}
|
|
>
|
|
{m === "static_credentials" ? "Keys" : m === "profile" ? "Profile" : "Token"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* AWS Region (always shown) */}
|
|
<div>
|
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region</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</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</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)</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</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</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)</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>
|
|
);
|
|
})()}
|
|
</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)}
|
|
/>
|
|
)}
|
|
|
|
{showClaudeInstructionsModal && (
|
|
<ClaudeInstructionsModal
|
|
instructions={claudeInstructions}
|
|
disabled={!isStopped}
|
|
onSave={async (instructions) => {
|
|
setClaudeInstructions(instructions);
|
|
await update({ ...project, claude_instructions: instructions || null });
|
|
}}
|
|
onClose={() => setShowClaudeInstructionsModal(false)}
|
|
/>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|