Fix frontend UX: debounce saves, Zustand selectors, init race, dialog
- Debounce project config saves: use local state + save-on-blur instead of firing IPC requests on every keystroke in text inputs - Add Zustand selectors to all store consumers to prevent full-store re-renders on any state change - Fix initialization race: chain checkImage after checkDocker resolves - Fix DockerSettings setTimeout race: await checkImage after save - Add console.error logging to all 11 empty catch blocks in ProjectCard - Add keyboard support to AddProjectDialog: Escape to close, click-outside-to-close, form submit on Enter, auto-focus Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import ProjectList from "../projects/ProjectList";
|
||||
import SettingsPanel from "../settings/SettingsPanel";
|
||||
|
||||
export default function Sidebar() {
|
||||
const { sidebarView, setSidebarView } = useAppState();
|
||||
const { sidebarView, setSidebarView } = useAppState(
|
||||
useShallow(s => ({ sidebarView: s.sidebarView, setSidebarView: s.setSidebarView }))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-[25%] min-w-56 max-w-80 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAppState } from "../../store/appState";
|
||||
|
||||
export default function StatusBar() {
|
||||
const { projects, sessions } = useAppState();
|
||||
const { projects, sessions } = useAppState(
|
||||
useShallow(s => ({ projects: s.projects, sessions: s.sessions }))
|
||||
);
|
||||
const running = projects.filter((p) => p.status === "running").length;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import TerminalTabs from "../terminal/TerminalTabs";
|
||||
import { useAppState } from "../../store/appState";
|
||||
|
||||
export default function TopBar() {
|
||||
const { dockerAvailable, imageExists } = useAppState();
|
||||
const { dockerAvailable, imageExists } = useAppState(
|
||||
useShallow(s => ({ dockerAvailable: s.dockerAvailable, imageExists: s.imageExists }))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { useProjects } from "../../hooks/useProjects";
|
||||
|
||||
@@ -12,6 +12,34 @@ export default function AddProjectDialog({ onClose }: Props) {
|
||||
const [path, setPath] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-focus the first input when the dialog opens
|
||||
useEffect(() => {
|
||||
nameInputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
// Close on click outside (click on overlay backdrop)
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const handleBrowse = async () => {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
@@ -24,7 +52,8 @@ export default function AddProjectDialog({ onClose }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!name.trim() || !path.trim()) {
|
||||
setError("Name and path are required");
|
||||
return;
|
||||
@@ -34,65 +63,74 @@ export default function AddProjectDialog({ onClose }: Props) {
|
||||
try {
|
||||
await add(name.trim(), path.trim());
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-96 shadow-xl">
|
||||
<h2 className="text-lg font-semibold mb-4">Add Project</h2>
|
||||
|
||||
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
||||
Project Name
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="my-project"
|
||||
className="w-full px-3 py-2 mb-3 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
|
||||
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
||||
Project Path
|
||||
</label>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
||||
Project Name
|
||||
</label>
|
||||
<input
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
placeholder="/path/to/project"
|
||||
className="flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
ref={nameInputRef}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="my-project"
|
||||
className="w-full px-3 py-2 mb-3 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
className="px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded text-sm hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-[var(--error)] mb-3">{error}</div>
|
||||
)}
|
||||
<label className="block text-sm text-[var(--text-secondary)] mb-1">
|
||||
Project Path
|
||||
</label>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<input
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
placeholder="/path/to/project"
|
||||
className="flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBrowse}
|
||||
className="px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded text-sm hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? "Adding..." : "Add Project"}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-[var(--error)] mb-3">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? "Adding..." : "Add Project"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { Project, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types";
|
||||
import { useProjects } from "../../hooks/useProjects";
|
||||
@@ -10,7 +10,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function ProjectCard({ project }: Props) {
|
||||
const { selectedProjectId, setSelectedProject } = useAppState();
|
||||
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);
|
||||
@@ -19,6 +20,40 @@ export default function ProjectCard({ project }: Props) {
|
||||
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 [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(() => {
|
||||
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);
|
||||
@@ -79,7 +114,9 @@ export default function ProjectCard({ project }: Props) {
|
||||
try {
|
||||
const current = project.bedrock_config ?? defaultBedrockConfig;
|
||||
await update({ ...project, bedrock_config: { ...current, ...patch } });
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
console.error("Failed to update Bedrock config:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseSSH = async () => {
|
||||
@@ -93,6 +130,118 @@ export default function ProjectCard({ project }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
// 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 handleClaudeInstructionsBlur = async () => {
|
||||
try {
|
||||
await update({ ...project, claude_instructions: claudeInstructions || null });
|
||||
} catch (err) {
|
||||
console.error("Failed to update Claude instructions:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnvVarBlur = async () => {
|
||||
try {
|
||||
await update({ ...project, custom_env_vars: envVars });
|
||||
} catch (err) {
|
||||
console.error("Failed to update environment variables:", 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)]",
|
||||
@@ -208,10 +357,9 @@ export default function ProjectCard({ project }: Props) {
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory</label>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={project.ssh_key_path ?? ""}
|
||||
onChange={async (e) => {
|
||||
try { await update({ ...project, ssh_key_path: e.target.value || null }); } catch {}
|
||||
}}
|
||||
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"
|
||||
@@ -230,10 +378,9 @@ export default function ProjectCard({ project }: Props) {
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name</label>
|
||||
<input
|
||||
value={project.git_user_name ?? ""}
|
||||
onChange={async (e) => {
|
||||
try { await update({ ...project, git_user_name: e.target.value || null }); } catch {}
|
||||
}}
|
||||
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"
|
||||
@@ -244,10 +391,9 @@ export default function ProjectCard({ project }: Props) {
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email</label>
|
||||
<input
|
||||
value={project.git_user_email ?? ""}
|
||||
onChange={async (e) => {
|
||||
try { await update({ ...project, git_user_email: e.target.value || null }); } catch {}
|
||||
}}
|
||||
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"
|
||||
@@ -259,10 +405,9 @@ export default function ProjectCard({ project }: Props) {
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={project.git_token ?? ""}
|
||||
onChange={async (e) => {
|
||||
try { await update({ ...project, git_token: e.target.value || null }); } catch {}
|
||||
}}
|
||||
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"
|
||||
@@ -274,7 +419,9 @@ export default function ProjectCard({ project }: Props) {
|
||||
<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 {}
|
||||
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 ${
|
||||
@@ -290,34 +437,39 @@ export default function ProjectCard({ project }: Props) {
|
||||
{/* Environment Variables */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Environment Variables</label>
|
||||
{(project.custom_env_vars ?? []).map((ev, i) => (
|
||||
{envVars.map((ev, i) => (
|
||||
<div key={i} className="flex gap-1 mb-1">
|
||||
<input
|
||||
value={ev.key}
|
||||
onChange={async (e) => {
|
||||
const vars = [...(project.custom_env_vars ?? [])];
|
||||
onChange={(e) => {
|
||||
const vars = [...envVars];
|
||||
vars[i] = { ...vars[i], key: e.target.value };
|
||||
try { await update({ ...project, custom_env_vars: vars }); } catch {}
|
||||
setEnvVars(vars);
|
||||
}}
|
||||
onBlur={handleEnvVarBlur}
|
||||
placeholder="KEY"
|
||||
disabled={!isStopped}
|
||||
className="w-1/3 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"
|
||||
/>
|
||||
<input
|
||||
value={ev.value}
|
||||
onChange={async (e) => {
|
||||
const vars = [...(project.custom_env_vars ?? [])];
|
||||
onChange={(e) => {
|
||||
const vars = [...envVars];
|
||||
vars[i] = { ...vars[i], value: e.target.value };
|
||||
try { await update({ ...project, custom_env_vars: vars }); } catch {}
|
||||
setEnvVars(vars);
|
||||
}}
|
||||
onBlur={handleEnvVarBlur}
|
||||
placeholder="value"
|
||||
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 font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const vars = (project.custom_env_vars ?? []).filter((_, j) => j !== i);
|
||||
try { await update({ ...project, custom_env_vars: vars }); } catch {}
|
||||
const vars = envVars.filter((_, j) => j !== i);
|
||||
setEnvVars(vars);
|
||||
try { await update({ ...project, custom_env_vars: vars }); } catch (err) {
|
||||
console.error("Failed to remove environment variable:", err);
|
||||
}
|
||||
}}
|
||||
disabled={!isStopped}
|
||||
className="px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
|
||||
@@ -328,8 +480,11 @@ export default function ProjectCard({ project }: Props) {
|
||||
))}
|
||||
<button
|
||||
onClick={async () => {
|
||||
const vars = [...(project.custom_env_vars ?? []), { key: "", value: "" }];
|
||||
try { await update({ ...project, custom_env_vars: vars }); } catch {}
|
||||
const vars = [...envVars, { key: "", value: "" }];
|
||||
setEnvVars(vars);
|
||||
try { await update({ ...project, custom_env_vars: vars }); } catch (err) {
|
||||
console.error("Failed to add environment variable:", err);
|
||||
}
|
||||
}}
|
||||
disabled={!isStopped}
|
||||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||
@@ -342,10 +497,9 @@ export default function ProjectCard({ project }: Props) {
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Claude Instructions</label>
|
||||
<textarea
|
||||
value={project.claude_instructions ?? ""}
|
||||
onChange={async (e) => {
|
||||
try { await update({ ...project, claude_instructions: e.target.value || null }); } catch {}
|
||||
}}
|
||||
value={claudeInstructions}
|
||||
onChange={(e) => setClaudeInstructions(e.target.value)}
|
||||
onBlur={handleClaudeInstructionsBlur}
|
||||
placeholder="Per-project instructions for Claude Code (written to ~/.claude/CLAUDE.md in container)"
|
||||
disabled={!isStopped}
|
||||
rows={3}
|
||||
@@ -384,8 +538,9 @@ export default function ProjectCard({ project }: Props) {
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region</label>
|
||||
<input
|
||||
value={bc.aws_region}
|
||||
onChange={(e) => updateBedrockConfig({ aws_region: e.target.value })}
|
||||
value={bedrockRegion}
|
||||
onChange={(e) => setBedrockRegion(e.target.value)}
|
||||
onBlur={handleBedrockRegionBlur}
|
||||
placeholder="us-east-1"
|
||||
disabled={!isStopped}
|
||||
className={inputCls}
|
||||
@@ -398,8 +553,9 @@ export default function ProjectCard({ project }: Props) {
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID</label>
|
||||
<input
|
||||
value={bc.aws_access_key_id ?? ""}
|
||||
onChange={(e) => updateBedrockConfig({ aws_access_key_id: e.target.value || null })}
|
||||
value={bedrockAccessKeyId}
|
||||
onChange={(e) => setBedrockAccessKeyId(e.target.value)}
|
||||
onBlur={handleBedrockAccessKeyIdBlur}
|
||||
placeholder="AKIA..."
|
||||
disabled={!isStopped}
|
||||
className={inputCls}
|
||||
@@ -409,8 +565,9 @@ export default function ProjectCard({ project }: Props) {
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={bc.aws_secret_access_key ?? ""}
|
||||
onChange={(e) => updateBedrockConfig({ aws_secret_access_key: e.target.value || null })}
|
||||
value={bedrockSecretKey}
|
||||
onChange={(e) => setBedrockSecretKey(e.target.value)}
|
||||
onBlur={handleBedrockSecretKeyBlur}
|
||||
disabled={!isStopped}
|
||||
className={inputCls}
|
||||
/>
|
||||
@@ -419,8 +576,9 @@ export default function ProjectCard({ project }: Props) {
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={bc.aws_session_token ?? ""}
|
||||
onChange={(e) => updateBedrockConfig({ aws_session_token: e.target.value || null })}
|
||||
value={bedrockSessionToken}
|
||||
onChange={(e) => setBedrockSessionToken(e.target.value)}
|
||||
onBlur={handleBedrockSessionTokenBlur}
|
||||
disabled={!isStopped}
|
||||
className={inputCls}
|
||||
/>
|
||||
@@ -433,8 +591,9 @@ export default function ProjectCard({ project }: Props) {
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile</label>
|
||||
<input
|
||||
value={bc.aws_profile ?? ""}
|
||||
onChange={(e) => updateBedrockConfig({ aws_profile: e.target.value || null })}
|
||||
value={bedrockProfile}
|
||||
onChange={(e) => setBedrockProfile(e.target.value)}
|
||||
onBlur={handleBedrockProfileBlur}
|
||||
placeholder="default"
|
||||
disabled={!isStopped}
|
||||
className={inputCls}
|
||||
@@ -448,8 +607,9 @@ export default function ProjectCard({ project }: Props) {
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={bc.aws_bearer_token ?? ""}
|
||||
onChange={(e) => updateBedrockConfig({ aws_bearer_token: e.target.value || null })}
|
||||
value={bedrockBearerToken}
|
||||
onChange={(e) => setBedrockBearerToken(e.target.value)}
|
||||
onBlur={handleBedrockBearerTokenBlur}
|
||||
disabled={!isStopped}
|
||||
className={inputCls}
|
||||
/>
|
||||
@@ -460,8 +620,9 @@ export default function ProjectCard({ project }: Props) {
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)</label>
|
||||
<input
|
||||
value={bc.model_id ?? ""}
|
||||
onChange={(e) => updateBedrockConfig({ model_id: e.target.value || null })}
|
||||
value={bedrockModelId}
|
||||
onChange={(e) => setBedrockModelId(e.target.value)}
|
||||
onBlur={handleBedrockModelIdBlur}
|
||||
placeholder="anthropic.claude-sonnet-4-20250514-v1:0"
|
||||
disabled={!isStopped}
|
||||
className={inputCls}
|
||||
|
||||
@@ -33,8 +33,7 @@ export default function DockerSettings() {
|
||||
const handleSourceChange = async (source: ImageSource) => {
|
||||
if (!appSettings) return;
|
||||
await saveSettings({ ...appSettings, image_source: source });
|
||||
// Re-check image existence after changing source
|
||||
setTimeout(() => checkImage(), 100);
|
||||
await checkImage();
|
||||
};
|
||||
|
||||
const handleCustomChange = async (value: string) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import ApiKeyInput from "./ApiKeyInput";
|
||||
import DockerSettings from "./DockerSettings";
|
||||
import AwsSettings from "./AwsSettings";
|
||||
@@ -5,6 +6,17 @@ import { useSettings } from "../../hooks/useSettings";
|
||||
|
||||
export default function SettingsPanel() {
|
||||
const { appSettings, saveSettings } = useSettings();
|
||||
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
||||
|
||||
// Sync local state when appSettings change
|
||||
useEffect(() => {
|
||||
setGlobalInstructions(appSettings?.global_claude_instructions ?? "");
|
||||
}, [appSettings?.global_claude_instructions]);
|
||||
|
||||
const handleInstructionsBlur = async () => {
|
||||
if (!appSettings) return;
|
||||
await saveSettings({ ...appSettings, global_claude_instructions: globalInstructions || null });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
@@ -20,11 +32,9 @@ export default function SettingsPanel() {
|
||||
Global instructions applied to all projects (written to ~/.claude/CLAUDE.md in containers)
|
||||
</p>
|
||||
<textarea
|
||||
value={appSettings?.global_claude_instructions ?? ""}
|
||||
onChange={async (e) => {
|
||||
if (!appSettings) return;
|
||||
await saveSettings({ ...appSettings, global_claude_instructions: e.target.value || null });
|
||||
}}
|
||||
value={globalInstructions}
|
||||
onChange={(e) => setGlobalInstructions(e.target.value)}
|
||||
onBlur={handleInstructionsBlur}
|
||||
placeholder="Instructions for Claude Code in all project containers..."
|
||||
rows={4}
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)] resize-y font-mono"
|
||||
|
||||
Reference in New Issue
Block a user