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:
2026-02-28 20:42:40 +00:00
parent a03bdccdc7
commit 82f159d2a9
12 changed files with 370 additions and 114 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useShallow } from "zustand/react/shallow";
import Sidebar from "./components/layout/Sidebar"; import Sidebar from "./components/layout/Sidebar";
import TopBar from "./components/layout/TopBar"; import TopBar from "./components/layout/TopBar";
import StatusBar from "./components/layout/StatusBar"; import StatusBar from "./components/layout/StatusBar";
@@ -12,13 +13,16 @@ export default function App() {
const { checkDocker, checkImage } = useDocker(); const { checkDocker, checkImage } = useDocker();
const { checkApiKey, loadSettings } = useSettings(); const { checkApiKey, loadSettings } = useSettings();
const { refresh } = useProjects(); const { refresh } = useProjects();
const { sessions, activeSessionId } = useAppState(); const { sessions, activeSessionId } = useAppState(
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId }))
);
// Initialize on mount // Initialize on mount
useEffect(() => { useEffect(() => {
loadSettings(); loadSettings();
checkDocker(); checkDocker().then((available) => {
checkImage(); if (available) checkImage();
});
checkApiKey(); checkApiKey();
refresh(); refresh();
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps

View File

@@ -1,9 +1,12 @@
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
import ProjectList from "../projects/ProjectList"; import ProjectList from "../projects/ProjectList";
import SettingsPanel from "../settings/SettingsPanel"; import SettingsPanel from "../settings/SettingsPanel";
export default function Sidebar() { export default function Sidebar() {
const { sidebarView, setSidebarView } = useAppState(); const { sidebarView, setSidebarView } = useAppState(
useShallow(s => ({ sidebarView: s.sidebarView, setSidebarView: s.setSidebarView }))
);
return ( 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"> <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">

View File

@@ -1,7 +1,10 @@
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
export default function StatusBar() { 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; const running = projects.filter((p) => p.status === "running").length;
return ( return (

View File

@@ -1,8 +1,11 @@
import { useShallow } from "zustand/react/shallow";
import TerminalTabs from "../terminal/TerminalTabs"; import TerminalTabs from "../terminal/TerminalTabs";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
export default function TopBar() { export default function TopBar() {
const { dockerAvailable, imageExists } = useAppState(); const { dockerAvailable, imageExists } = useAppState(
useShallow(s => ({ dockerAvailable: s.dockerAvailable, imageExists: s.imageExists }))
);
return ( return (
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden"> <div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { useProjects } from "../../hooks/useProjects"; import { useProjects } from "../../hooks/useProjects";
@@ -12,6 +12,34 @@ export default function AddProjectDialog({ onClose }: Props) {
const [path, setPath] = useState(""); const [path, setPath] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); 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 handleBrowse = async () => {
const selected = await open({ directory: true, multiple: false }); 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()) { if (!name.trim() || !path.trim()) {
setError("Name and path are required"); setError("Name and path are required");
return; return;
@@ -34,65 +63,74 @@ export default function AddProjectDialog({ onClose }: Props) {
try { try {
await add(name.trim(), path.trim()); await add(name.trim(), path.trim());
onClose(); onClose();
} catch (e) { } catch (err) {
setError(String(e)); setError(String(err));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( 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"> <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> <h2 className="text-lg font-semibold mb-4">Add Project</h2>
<label className="block text-sm text-[var(--text-secondary)] mb-1"> <form onSubmit={handleSubmit}>
Project Name <label className="block text-sm text-[var(--text-secondary)] mb-1">
</label> Project Name
<input </label>
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">
<input <input
value={path} ref={nameInputRef}
onChange={(e) => setPath(e.target.value)} value={name}
placeholder="/path/to/project" onChange={(e) => setName(e.target.value)}
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)]" 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 && ( <label className="block text-sm text-[var(--text-secondary)] mb-1">
<div className="text-xs text-[var(--error)] mb-3">{error}</div> 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"> {error && (
<button <div className="text-xs text-[var(--error)] mb-3">{error}</div>
onClick={onClose} )}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
> <div className="flex justify-end gap-2">
Cancel <button
</button> type="button"
<button onClick={onClose}
onClick={handleSubmit} className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
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" Cancel
> </button>
{loading ? "Adding..." : "Add Project"} <button
</button> type="submit"
</div> 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>
</div> </div>
); );

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import type { Project, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types"; import type { Project, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types";
import { useProjects } from "../../hooks/useProjects"; import { useProjects } from "../../hooks/useProjects";
@@ -10,7 +10,8 @@ interface Props {
} }
export default function ProjectCard({ project }: 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 { start, stop, rebuild, remove, update } = useProjects();
const { open: openTerminal } = useTerminal(); const { open: openTerminal } = useTerminal();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -19,6 +20,40 @@ export default function ProjectCard({ project }: Props) {
const isSelected = selectedProjectId === project.id; const isSelected = selectedProjectId === project.id;
const isStopped = project.status === "stopped" || project.status === "error"; 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 () => { const handleStart = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -79,7 +114,9 @@ export default function ProjectCard({ project }: Props) {
try { try {
const current = project.bedrock_config ?? defaultBedrockConfig; const current = project.bedrock_config ?? defaultBedrockConfig;
await update({ ...project, bedrock_config: { ...current, ...patch } }); await update({ ...project, bedrock_config: { ...current, ...patch } });
} catch {} } catch (err) {
console.error("Failed to update Bedrock config:", err);
}
}; };
const handleBrowseSSH = async () => { 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 = { const statusColor = {
stopped: "bg-[var(--text-secondary)]", stopped: "bg-[var(--text-secondary)]",
starting: "bg-[var(--warning)]", 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> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory</label>
<div className="flex gap-1"> <div className="flex gap-1">
<input <input
value={project.ssh_key_path ?? ""} value={sshKeyPath}
onChange={async (e) => { onChange={(e) => setSshKeyPath(e.target.value)}
try { await update({ ...project, ssh_key_path: e.target.value || null }); } catch {} onBlur={handleSshKeyPathBlur}
}}
placeholder="~/.ssh" placeholder="~/.ssh"
disabled={!isStopped} 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" 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> <div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name</label> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name</label>
<input <input
value={project.git_user_name ?? ""} value={gitName}
onChange={async (e) => { onChange={(e) => setGitName(e.target.value)}
try { await update({ ...project, git_user_name: e.target.value || null }); } catch {} onBlur={handleGitNameBlur}
}}
placeholder="Your Name" placeholder="Your Name"
disabled={!isStopped} 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" 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> <div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email</label> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email</label>
<input <input
value={project.git_user_email ?? ""} value={gitEmail}
onChange={async (e) => { onChange={(e) => setGitEmail(e.target.value)}
try { await update({ ...project, git_user_email: e.target.value || null }); } catch {} onBlur={handleGitEmailBlur}
}}
placeholder="you@example.com" placeholder="you@example.com"
disabled={!isStopped} 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" 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> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token</label>
<input <input
type="password" type="password"
value={project.git_token ?? ""} value={gitToken}
onChange={async (e) => { onChange={(e) => setGitToken(e.target.value)}
try { await update({ ...project, git_token: e.target.value || null }); } catch {} onBlur={handleGitTokenBlur}
}}
placeholder="ghp_..." placeholder="ghp_..."
disabled={!isStopped} 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" 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> <label className="text-xs text-[var(--text-secondary)]">Allow container spawning</label>
<button <button
onClick={async () => { 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} disabled={!isStopped}
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${ 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 */} {/* Environment Variables */}
<div> <div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Environment Variables</label> <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"> <div key={i} className="flex gap-1 mb-1">
<input <input
value={ev.key} value={ev.key}
onChange={async (e) => { onChange={(e) => {
const vars = [...(project.custom_env_vars ?? [])]; const vars = [...envVars];
vars[i] = { ...vars[i], key: e.target.value }; vars[i] = { ...vars[i], key: e.target.value };
try { await update({ ...project, custom_env_vars: vars }); } catch {} setEnvVars(vars);
}} }}
onBlur={handleEnvVarBlur}
placeholder="KEY" placeholder="KEY"
disabled={!isStopped} 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" 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 <input
value={ev.value} value={ev.value}
onChange={async (e) => { onChange={(e) => {
const vars = [...(project.custom_env_vars ?? [])]; const vars = [...envVars];
vars[i] = { ...vars[i], value: e.target.value }; vars[i] = { ...vars[i], value: e.target.value };
try { await update({ ...project, custom_env_vars: vars }); } catch {} setEnvVars(vars);
}} }}
onBlur={handleEnvVarBlur}
placeholder="value" placeholder="value"
disabled={!isStopped} 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" 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 <button
onClick={async () => { onClick={async () => {
const vars = (project.custom_env_vars ?? []).filter((_, j) => j !== i); const vars = envVars.filter((_, j) => j !== i);
try { await update({ ...project, custom_env_vars: vars }); } catch {} setEnvVars(vars);
try { await update({ ...project, custom_env_vars: vars }); } catch (err) {
console.error("Failed to remove environment variable:", err);
}
}} }}
disabled={!isStopped} disabled={!isStopped}
className="px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors" 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 <button
onClick={async () => { onClick={async () => {
const vars = [...(project.custom_env_vars ?? []), { key: "", value: "" }]; const vars = [...envVars, { key: "", value: "" }];
try { await update({ ...project, custom_env_vars: vars }); } catch {} setEnvVars(vars);
try { await update({ ...project, custom_env_vars: vars }); } catch (err) {
console.error("Failed to add environment variable:", err);
}
}} }}
disabled={!isStopped} disabled={!isStopped}
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors" 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> <div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Claude Instructions</label> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">Claude Instructions</label>
<textarea <textarea
value={project.claude_instructions ?? ""} value={claudeInstructions}
onChange={async (e) => { onChange={(e) => setClaudeInstructions(e.target.value)}
try { await update({ ...project, claude_instructions: e.target.value || null }); } catch {} onBlur={handleClaudeInstructionsBlur}
}}
placeholder="Per-project instructions for Claude Code (written to ~/.claude/CLAUDE.md in container)" placeholder="Per-project instructions for Claude Code (written to ~/.claude/CLAUDE.md in container)"
disabled={!isStopped} disabled={!isStopped}
rows={3} rows={3}
@@ -384,8 +538,9 @@ export default function ProjectCard({ project }: Props) {
<div> <div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region</label> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region</label>
<input <input
value={bc.aws_region} value={bedrockRegion}
onChange={(e) => updateBedrockConfig({ aws_region: e.target.value })} onChange={(e) => setBedrockRegion(e.target.value)}
onBlur={handleBedrockRegionBlur}
placeholder="us-east-1" placeholder="us-east-1"
disabled={!isStopped} disabled={!isStopped}
className={inputCls} className={inputCls}
@@ -398,8 +553,9 @@ export default function ProjectCard({ project }: Props) {
<div> <div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID</label> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID</label>
<input <input
value={bc.aws_access_key_id ?? ""} value={bedrockAccessKeyId}
onChange={(e) => updateBedrockConfig({ aws_access_key_id: e.target.value || null })} onChange={(e) => setBedrockAccessKeyId(e.target.value)}
onBlur={handleBedrockAccessKeyIdBlur}
placeholder="AKIA..." placeholder="AKIA..."
disabled={!isStopped} disabled={!isStopped}
className={inputCls} 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> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key</label>
<input <input
type="password" type="password"
value={bc.aws_secret_access_key ?? ""} value={bedrockSecretKey}
onChange={(e) => updateBedrockConfig({ aws_secret_access_key: e.target.value || null })} onChange={(e) => setBedrockSecretKey(e.target.value)}
onBlur={handleBedrockSecretKeyBlur}
disabled={!isStopped} disabled={!isStopped}
className={inputCls} 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> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)</label>
<input <input
type="password" type="password"
value={bc.aws_session_token ?? ""} value={bedrockSessionToken}
onChange={(e) => updateBedrockConfig({ aws_session_token: e.target.value || null })} onChange={(e) => setBedrockSessionToken(e.target.value)}
onBlur={handleBedrockSessionTokenBlur}
disabled={!isStopped} disabled={!isStopped}
className={inputCls} className={inputCls}
/> />
@@ -433,8 +591,9 @@ export default function ProjectCard({ project }: Props) {
<div> <div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile</label> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile</label>
<input <input
value={bc.aws_profile ?? ""} value={bedrockProfile}
onChange={(e) => updateBedrockConfig({ aws_profile: e.target.value || null })} onChange={(e) => setBedrockProfile(e.target.value)}
onBlur={handleBedrockProfileBlur}
placeholder="default" placeholder="default"
disabled={!isStopped} disabled={!isStopped}
className={inputCls} 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> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token</label>
<input <input
type="password" type="password"
value={bc.aws_bearer_token ?? ""} value={bedrockBearerToken}
onChange={(e) => updateBedrockConfig({ aws_bearer_token: e.target.value || null })} onChange={(e) => setBedrockBearerToken(e.target.value)}
onBlur={handleBedrockBearerTokenBlur}
disabled={!isStopped} disabled={!isStopped}
className={inputCls} className={inputCls}
/> />
@@ -460,8 +620,9 @@ export default function ProjectCard({ project }: Props) {
<div> <div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)</label> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)</label>
<input <input
value={bc.model_id ?? ""} value={bedrockModelId}
onChange={(e) => updateBedrockConfig({ model_id: e.target.value || null })} onChange={(e) => setBedrockModelId(e.target.value)}
onBlur={handleBedrockModelIdBlur}
placeholder="anthropic.claude-sonnet-4-20250514-v1:0" placeholder="anthropic.claude-sonnet-4-20250514-v1:0"
disabled={!isStopped} disabled={!isStopped}
className={inputCls} className={inputCls}

View File

@@ -33,8 +33,7 @@ export default function DockerSettings() {
const handleSourceChange = async (source: ImageSource) => { const handleSourceChange = async (source: ImageSource) => {
if (!appSettings) return; if (!appSettings) return;
await saveSettings({ ...appSettings, image_source: source }); await saveSettings({ ...appSettings, image_source: source });
// Re-check image existence after changing source await checkImage();
setTimeout(() => checkImage(), 100);
}; };
const handleCustomChange = async (value: string) => { const handleCustomChange = async (value: string) => {

View File

@@ -1,3 +1,4 @@
import { useState, useEffect } from "react";
import ApiKeyInput from "./ApiKeyInput"; import ApiKeyInput from "./ApiKeyInput";
import DockerSettings from "./DockerSettings"; import DockerSettings from "./DockerSettings";
import AwsSettings from "./AwsSettings"; import AwsSettings from "./AwsSettings";
@@ -5,6 +6,17 @@ import { useSettings } from "../../hooks/useSettings";
export default function SettingsPanel() { export default function SettingsPanel() {
const { appSettings, saveSettings } = useSettings(); 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 ( return (
<div className="p-4 space-y-6"> <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) Global instructions applied to all projects (written to ~/.claude/CLAUDE.md in containers)
</p> </p>
<textarea <textarea
value={appSettings?.global_claude_instructions ?? ""} value={globalInstructions}
onChange={async (e) => { onChange={(e) => setGlobalInstructions(e.target.value)}
if (!appSettings) return; onBlur={handleInstructionsBlur}
await saveSettings({ ...appSettings, global_claude_instructions: e.target.value || null });
}}
placeholder="Instructions for Claude Code in all project containers..." placeholder="Instructions for Claude Code in all project containers..."
rows={4} 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" 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"

View File

@@ -1,4 +1,5 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useShallow } from "zustand/react/shallow";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { useAppState } from "../store/appState"; import { useAppState } from "../store/appState";
import * as commands from "../lib/tauri-commands"; import * as commands from "../lib/tauri-commands";
@@ -9,7 +10,14 @@ export function useDocker() {
setDockerAvailable, setDockerAvailable,
imageExists, imageExists,
setImageExists, setImageExists,
} = useAppState(); } = useAppState(
useShallow(s => ({
dockerAvailable: s.dockerAvailable,
setDockerAvailable: s.setDockerAvailable,
imageExists: s.imageExists,
setImageExists: s.setImageExists,
}))
);
const checkDocker = useCallback(async () => { const checkDocker = useCallback(async () => {
try { try {

View File

@@ -1,4 +1,5 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../store/appState"; import { useAppState } from "../store/appState";
import * as commands from "../lib/tauri-commands"; import * as commands from "../lib/tauri-commands";
@@ -10,7 +11,16 @@ export function useProjects() {
setSelectedProject, setSelectedProject,
updateProjectInList, updateProjectInList,
removeProjectFromList, removeProjectFromList,
} = useAppState(); } = useAppState(
useShallow(s => ({
projects: s.projects,
selectedProjectId: s.selectedProjectId,
setProjects: s.setProjects,
setSelectedProject: s.setSelectedProject,
updateProjectInList: s.updateProjectInList,
removeProjectFromList: s.removeProjectFromList,
}))
);
const selectedProject = projects.find((p) => p.id === selectedProjectId) ?? null; const selectedProject = projects.find((p) => p.id === selectedProjectId) ?? null;

View File

@@ -1,10 +1,18 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../store/appState"; import { useAppState } from "../store/appState";
import * as commands from "../lib/tauri-commands"; import * as commands from "../lib/tauri-commands";
import type { AppSettings } from "../lib/types"; import type { AppSettings } from "../lib/types";
export function useSettings() { export function useSettings() {
const { hasKey, setHasKey, appSettings, setAppSettings } = useAppState(); const { hasKey, setHasKey, appSettings, setAppSettings } = useAppState(
useShallow(s => ({
hasKey: s.hasKey,
setHasKey: s.setHasKey,
appSettings: s.appSettings,
setAppSettings: s.setAppSettings,
}))
);
const checkApiKey = useCallback(async () => { const checkApiKey = useCallback(async () => {
try { try {

View File

@@ -1,11 +1,20 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useShallow } from "zustand/react/shallow";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { useAppState } from "../store/appState"; import { useAppState } from "../store/appState";
import * as commands from "../lib/tauri-commands"; import * as commands from "../lib/tauri-commands";
export function useTerminal() { export function useTerminal() {
const { sessions, activeSessionId, addSession, removeSession, setActiveSession } = const { sessions, activeSessionId, addSession, removeSession, setActiveSession } =
useAppState(); useAppState(
useShallow(s => ({
sessions: s.sessions,
activeSessionId: s.activeSessionId,
addSession: s.addSession,
removeSession: s.removeSession,
setActiveSession: s.setActiveSession,
}))
);
const open = useCallback( const open = useCallback(
async (projectId: string, projectName: string) => { async (projectId: string, projectName: string) => {