Add Project Home, Auth Bridge, shared auth token, and Tier-1 polish
Project Home (DESIGN-REVIEW §B2): the project is promoted from a 280px
sidebar card to a first-class main-area view. ProjectCard.tsx (1,257
lines) is replaced by a select-only ProjectRow plus tabs for Overview,
Sessions, Automation, Config and Files. The PortMappings, FileManager
and ContainerProgress modals are absorbed rather than reimplemented.
Config gains a Saved/Saving/Failed indicator — save-on-blur failures
previously reached only console.error.
Tier-1 polish (DESIGN-REVIEW §A): new elevation, muted-accent, disabled
and focus-ring tokens; a global :focus-visible ring with every
focus:outline-none removed; filled buttons moved to --accent-emphasis
and white-on-success toggles retired, fixing three WCAG AA failures
(2.1:1, 2.5:1, 2.4:1); a shared Modal primitive with role="dialog",
focus trap and restore, adopted by all remaining modals; status
indicators that carry a glyph and word rather than colour alone.
Ctrl+Shift+W closes a tab, deliberately not Ctrl+W — that is readline's
kill-word, used constantly in the terminal this app is built around.
Auth Bridge: a general loopback-callback bridge so browser logins run
inside a container (aws sso login, Concourse fly login, claude login)
can complete against the host browser. Listeners are discovered from
/proc/net/tcp{,6} — ss/netstat/lsof are absent from the image — bound on
host 127.0.0.1 only, and tunnelled in over the Docker API via socat,
which keeps working on Docker Desktop where container IPs are not
routable. Falls back to [::1] because Node resolves localhost to IPv6
first, so claude login often binds ::1 alone. Opt-in per project.
This extracts create_attached_exec() and moves the existing terminal
session path onto it, so there is one attached-exec implementation
rather than two.
Shared auth token: `claude setup-token` is run in a container, the token
is stored in the OS keychain and injected as CLAUDE_CODE_OAUTH_TOKEN
into Anthropic-backend projects. Contrary to the initial design note,
setup-token uses an Anthropic-hosted redirect and blocks on a stdin
paste prompt rather than a loopback callback, so a stdin command is
required for the flow to complete.
The token is never logged, never returned to the frontend, and is
redacted from the streamed output with a stateful matcher that withholds
any tail that could still grow into a secret. Change detection uses a
random rotation id rather than a hash, since a hash in a docker-inspect
readable label would be an offline verification oracle.
Frontend 33 -> 51 tests; Rust 34 tests. Both builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { Project } from "../../../../lib/types";
|
||||
import Button from "../../../ui/Button";
|
||||
import Field, { ConfigGroup, inputClass } from "../../../ui/Field";
|
||||
import EnvVarsEditor from "../../EnvVarsEditor";
|
||||
import PortMappingsEditor from "../../PortMappingsEditor";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
export default function AccessSection({
|
||||
project,
|
||||
save,
|
||||
disabled,
|
||||
disabledReason,
|
||||
}: Props) {
|
||||
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 ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
setSshKeyPath(project.ssh_key_path ?? "");
|
||||
setGitName(project.git_user_name ?? "");
|
||||
setGitEmail(project.git_user_email ?? "");
|
||||
setGitToken(project.git_token ?? "");
|
||||
}, [project]);
|
||||
|
||||
return (
|
||||
<ConfigGroup
|
||||
title="Access"
|
||||
description="Credentials, environment, and networking the container is given."
|
||||
>
|
||||
<Field
|
||||
label="SSH key directory"
|
||||
hint="Mounted into the container so Claude can authenticate with Git remotes over SSH."
|
||||
>
|
||||
{(id) => (
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
id={id}
|
||||
value={sshKeyPath}
|
||||
onChange={(e) => setSshKeyPath(e.target.value)}
|
||||
onBlur={() => save({ ssh_key_path: sshKeyPath || null })}
|
||||
placeholder="~/.ssh"
|
||||
disabled={disabled}
|
||||
className={inputClass}
|
||||
/>
|
||||
<Button
|
||||
size="md"
|
||||
disabled={disabled}
|
||||
onClick={async () => {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (typeof selected === "string") {
|
||||
setSshKeyPath(selected);
|
||||
save({ ssh_key_path: selected });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Git name" hint="Sets git user.name inside the container for commit authorship.">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={gitName}
|
||||
onChange={(e) => setGitName(e.target.value)}
|
||||
onBlur={() => save({ git_user_name: gitName || null })}
|
||||
placeholder="Your Name"
|
||||
disabled={disabled}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Git email" hint="Sets git user.email inside the container for commit authorship.">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={gitEmail}
|
||||
onChange={(e) => setGitEmail(e.target.value)}
|
||||
onBlur={() => save({ git_user_email: gitEmail || null })}
|
||||
placeholder="you@example.com"
|
||||
disabled={disabled}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Git HTTPS token"
|
||||
hint="A personal access token (e.g. a GitHub PAT) for HTTPS git operations inside the container."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={gitToken}
|
||||
onChange={(e) => setGitToken(e.target.value)}
|
||||
onBlur={() => save({ git_token: gitToken || null })}
|
||||
placeholder="ghp_…"
|
||||
disabled={disabled}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div className="pt-2 border-t border-[var(--border-color)]">
|
||||
<span className="block text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Environment variables
|
||||
</span>
|
||||
<p className="mt-0.5 mb-2 text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Injected into this project’s container. These override global variables
|
||||
with the same key.
|
||||
</p>
|
||||
<EnvVarsEditor
|
||||
envVars={project.custom_env_vars ?? []}
|
||||
disabled={disabled}
|
||||
disabledReason={disabledReason}
|
||||
onSave={(vars) => save({ custom_env_vars: vars })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-[var(--border-color)]">
|
||||
<span className="block text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Port mappings
|
||||
</span>
|
||||
<p className="mt-0.5 mb-2 text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Expose container ports on the host so you can reach dev servers running inside
|
||||
the sandbox.
|
||||
</p>
|
||||
<PortMappingsEditor
|
||||
portMappings={project.port_mappings ?? []}
|
||||
disabled={disabled}
|
||||
disabledReason={disabledReason}
|
||||
onSave={(mappings) => save({ port_mappings: mappings })}
|
||||
/>
|
||||
</div>
|
||||
</ConfigGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type {
|
||||
Backend,
|
||||
BedrockAuthMethod,
|
||||
BedrockConfig,
|
||||
OllamaConfig,
|
||||
OpenAiCompatibleConfig,
|
||||
Project,
|
||||
} from "../../../../lib/types";
|
||||
import Field, { ConfigGroup, monoInputClass, selectClass } from "../../../ui/Field";
|
||||
|
||||
export const DEFAULT_BEDROCK_CONFIG: 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,
|
||||
service_tier: null,
|
||||
};
|
||||
|
||||
export const DEFAULT_OLLAMA_CONFIG: OllamaConfig = {
|
||||
base_url: "http://host.docker.internal:11434",
|
||||
model_id: null,
|
||||
};
|
||||
|
||||
export const DEFAULT_OPENAI_COMPATIBLE_CONFIG: OpenAiCompatibleConfig = {
|
||||
base_url: "http://host.docker.internal:4000",
|
||||
api_key: null,
|
||||
model_id: null,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export default function ModelSection({ project, save, disabled }: Props) {
|
||||
const bedrock = project.bedrock_config ?? DEFAULT_BEDROCK_CONFIG;
|
||||
|
||||
// Local text state — saved on blur, not on every keystroke.
|
||||
const [bedrockRegion, setBedrockRegion] = useState(bedrock.aws_region);
|
||||
const [accessKeyId, setAccessKeyId] = useState(bedrock.aws_access_key_id ?? "");
|
||||
const [secretKey, setSecretKey] = useState(bedrock.aws_secret_access_key ?? "");
|
||||
const [sessionToken, setSessionToken] = useState(bedrock.aws_session_token ?? "");
|
||||
const [profile, setProfile] = useState(bedrock.aws_profile ?? "");
|
||||
const [bearerToken, setBearerToken] = useState(bedrock.aws_bearer_token ?? "");
|
||||
const [bedrockModelId, setBedrockModelId] = useState(bedrock.model_id ?? "");
|
||||
const [serviceTier, setServiceTier] = useState(bedrock.service_tier ?? "");
|
||||
|
||||
const [ollamaBaseUrl, setOllamaBaseUrl] = useState(
|
||||
project.ollama_config?.base_url ?? DEFAULT_OLLAMA_CONFIG.base_url,
|
||||
);
|
||||
const [ollamaModelId, setOllamaModelId] = useState(
|
||||
project.ollama_config?.model_id ?? "",
|
||||
);
|
||||
|
||||
const [oaiBaseUrl, setOaiBaseUrl] = useState(
|
||||
project.openai_compatible_config?.base_url ??
|
||||
DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url,
|
||||
);
|
||||
const [oaiApiKey, setOaiApiKey] = useState(
|
||||
project.openai_compatible_config?.api_key ?? "",
|
||||
);
|
||||
const [oaiModelId, setOaiModelId] = useState(
|
||||
project.openai_compatible_config?.model_id ?? "",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const bc = project.bedrock_config ?? DEFAULT_BEDROCK_CONFIG;
|
||||
setBedrockRegion(bc.aws_region);
|
||||
setAccessKeyId(bc.aws_access_key_id ?? "");
|
||||
setSecretKey(bc.aws_secret_access_key ?? "");
|
||||
setSessionToken(bc.aws_session_token ?? "");
|
||||
setProfile(bc.aws_profile ?? "");
|
||||
setBearerToken(bc.aws_bearer_token ?? "");
|
||||
setBedrockModelId(bc.model_id ?? "");
|
||||
setServiceTier(bc.service_tier ?? "");
|
||||
setOllamaBaseUrl(project.ollama_config?.base_url ?? DEFAULT_OLLAMA_CONFIG.base_url);
|
||||
setOllamaModelId(project.ollama_config?.model_id ?? "");
|
||||
setOaiBaseUrl(
|
||||
project.openai_compatible_config?.base_url ??
|
||||
DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url,
|
||||
);
|
||||
setOaiApiKey(project.openai_compatible_config?.api_key ?? "");
|
||||
setOaiModelId(project.openai_compatible_config?.model_id ?? "");
|
||||
}, [project]);
|
||||
|
||||
const saveBedrock = (patch: Partial<BedrockConfig>) =>
|
||||
save({ bedrock_config: { ...bedrock, ...patch } });
|
||||
|
||||
const saveOllama = (patch: Partial<OllamaConfig>) =>
|
||||
save({
|
||||
ollama_config: { ...(project.ollama_config ?? DEFAULT_OLLAMA_CONFIG), ...patch },
|
||||
});
|
||||
|
||||
const saveOpenAi = (patch: Partial<OpenAiCompatibleConfig>) =>
|
||||
save({
|
||||
openai_compatible_config: {
|
||||
...(project.openai_compatible_config ?? DEFAULT_OPENAI_COMPATIBLE_CONFIG),
|
||||
...patch,
|
||||
},
|
||||
});
|
||||
|
||||
const handleBackendChange = (mode: Backend) => {
|
||||
const patch: Partial<Project> = { backend: mode };
|
||||
if (mode === "bedrock" && !project.bedrock_config)
|
||||
patch.bedrock_config = DEFAULT_BEDROCK_CONFIG;
|
||||
if (mode === "ollama" && !project.ollama_config)
|
||||
patch.ollama_config = DEFAULT_OLLAMA_CONFIG;
|
||||
if (mode === "open_ai_compatible" && !project.openai_compatible_config)
|
||||
patch.openai_compatible_config = DEFAULT_OPENAI_COMPATIBLE_CONFIG;
|
||||
save(patch);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigGroup title="Model" description="Which provider serves this project's Claude.">
|
||||
<Field
|
||||
label="Backend"
|
||||
hint="Anthropic connects directly via OAuth (run `claude login` in a terminal). Bedrock routes through AWS. Ollama and OpenAI Compatible point at any compatible endpoint."
|
||||
>
|
||||
{(id) => (
|
||||
<select
|
||||
id={id}
|
||||
value={project.backend}
|
||||
onChange={(e) => handleBackendChange(e.target.value as Backend)}
|
||||
disabled={disabled}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="bedrock">Bedrock</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="open_ai_compatible">OpenAI Compatible</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{project.backend === "bedrock" && (
|
||||
<div className="space-y-4 pt-2 border-t border-[var(--border-color)]">
|
||||
<Field label="Authentication method" hint="How the container proves its identity to Bedrock.">
|
||||
{(id) => (
|
||||
<select
|
||||
id={id}
|
||||
value={bedrock.auth_method}
|
||||
onChange={(e) =>
|
||||
saveBedrock({ auth_method: e.target.value as BedrockAuthMethod })
|
||||
}
|
||||
disabled={disabled}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="static_credentials">Static keys</option>
|
||||
<option value="profile">Named profile</option>
|
||||
<option value="bearer_token">Bearer token</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="AWS region" hint="Region where your Bedrock endpoint is available.">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={bedrockRegion}
|
||||
onChange={(e) => setBedrockRegion(e.target.value)}
|
||||
onBlur={() => saveBedrock({ aws_region: bedrockRegion })}
|
||||
placeholder="us-east-1"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{bedrock.auth_method === "static_credentials" && (
|
||||
<>
|
||||
<Field label="Access key ID" hint="IAM access key used for Bedrock API calls.">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={accessKeyId}
|
||||
onChange={(e) => setAccessKeyId(e.target.value)}
|
||||
onBlur={() => saveBedrock({ aws_access_key_id: accessKeyId || null })}
|
||||
placeholder="AKIA…"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Secret access key"
|
||||
hint="Stored locally and injected as an env var into the container."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={secretKey}
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
onBlur={() =>
|
||||
saveBedrock({ aws_secret_access_key: secretKey || null })
|
||||
}
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Session token"
|
||||
hint="Optional — for assumed-role or MFA-based credentials."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={sessionToken}
|
||||
onChange={(e) => setSessionToken(e.target.value)}
|
||||
onBlur={() =>
|
||||
saveBedrock({ aws_session_token: sessionToken || null })
|
||||
}
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{bedrock.auth_method === "profile" && (
|
||||
<Field
|
||||
label="AWS profile"
|
||||
hint="Named profile from your AWS config/credentials files."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={profile}
|
||||
onChange={(e) => setProfile(e.target.value)}
|
||||
onBlur={() => saveBedrock({ aws_profile: profile || null })}
|
||||
placeholder="default"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{bedrock.auth_method === "bearer_token" && (
|
||||
<Field
|
||||
label="Bearer token"
|
||||
hint="SSO or identity-center token for Bedrock authentication."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={bearerToken}
|
||||
onChange={(e) => setBearerToken(e.target.value)}
|
||||
onBlur={() => saveBedrock({ aws_bearer_token: bearerToken || null })}
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Model ID" hint="Optional override. Leave blank for Claude's default.">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={bedrockModelId}
|
||||
onChange={(e) => setBedrockModelId(e.target.value)}
|
||||
onBlur={() => saveBedrock({ model_id: bedrockModelId || null })}
|
||||
placeholder="anthropic.claude-sonnet-4-20250514-v1:0"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Service tier"
|
||||
hint="Optional — sets ANTHROPIC_BEDROCK_SERVICE_TIER (e.g. “priority”)."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={serviceTier}
|
||||
onChange={(e) => setServiceTier(e.target.value)}
|
||||
onBlur={() => saveBedrock({ service_tier: serviceTier.trim() || null })}
|
||||
placeholder="(account default)"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.backend === "ollama" && (
|
||||
<div className="space-y-4 pt-2 border-t border-[var(--border-color)]">
|
||||
<Field
|
||||
label="Base URL"
|
||||
hint="Use host.docker.internal to reach the host machine, or an IP/hostname for a remote server."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={ollamaBaseUrl}
|
||||
onChange={(e) => setOllamaBaseUrl(e.target.value)}
|
||||
onBlur={() => saveOllama({ base_url: ollamaBaseUrl })}
|
||||
placeholder="http://host.docker.internal:11434"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Model"
|
||||
hint="Required. The model must already be pulled in Ollama before the container starts."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={ollamaModelId}
|
||||
onChange={(e) => setOllamaModelId(e.target.value)}
|
||||
onBlur={() => saveOllama({ model_id: ollamaModelId || null })}
|
||||
placeholder="qwen3.5:27b"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.backend === "open_ai_compatible" && (
|
||||
<div className="space-y-4 pt-2 border-t border-[var(--border-color)]">
|
||||
<Field
|
||||
label="Base URL"
|
||||
hint="Any OpenAI API-compatible endpoint — LiteLLM, OpenRouter, vLLM, and so on."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={oaiBaseUrl}
|
||||
onChange={(e) => setOaiBaseUrl(e.target.value)}
|
||||
onBlur={() => saveOpenAi({ base_url: oaiBaseUrl })}
|
||||
placeholder="http://host.docker.internal:4000"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="API key" hint="Authentication key for the endpoint, if it requires one.">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={oaiApiKey}
|
||||
onChange={(e) => setOaiApiKey(e.target.value)}
|
||||
onBlur={() => saveOpenAi({ api_key: oaiApiKey || null })}
|
||||
placeholder="sk-…"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Model" hint="Optional — model identifier as configured by your provider.">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={oaiModelId}
|
||||
onChange={(e) => setOaiModelId(e.target.value)}
|
||||
onBlur={() => saveOpenAi({ model_id: oaiModelId || null })}
|
||||
placeholder="gpt-4o / gemini-pro / …"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</ConfigGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Project } from "../../../../lib/types";
|
||||
import Toggle from "../../../ui/Toggle";
|
||||
import { ConfigGroup, SwitchRow } from "../../../ui/Field";
|
||||
import PermissionModeControl, { permissionModePatch } from "../../PermissionModeControl";
|
||||
import ClaudeInstructionsEditor from "../../ClaudeInstructionsEditor";
|
||||
import ClaudeCodeSettingsEditor from "../../ClaudeCodeSettingsEditor";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
export default function RuntimeSection({
|
||||
project,
|
||||
save,
|
||||
disabled,
|
||||
disabledReason,
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
<ConfigGroup
|
||||
title="Runtime"
|
||||
description="How much the sandbox lets Claude do, and what contains it."
|
||||
>
|
||||
<div className="pb-2 border-b border-[var(--border-color)]">
|
||||
<PermissionModeControl
|
||||
project={project}
|
||||
onChange={(mode) => save(permissionModePatch(mode))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SwitchRow
|
||||
label="Sandbox mode"
|
||||
hint="Claude Code's bash sandbox (bubblewrap filesystem and network isolation). Triple-C is the source of truth: toggling this overrides any manual /sandbox configuration in the container's settings.json on next start."
|
||||
control={
|
||||
<Toggle
|
||||
label="Sandbox mode"
|
||||
checked={project.sandbox_mode_enabled}
|
||||
disabled={disabled}
|
||||
onChange={(v) => save({ sandbox_mode_enabled: v })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SwitchRow
|
||||
label="Allow container spawning"
|
||||
hint="Mounts the Docker socket so Claude can build and run Docker containers from inside the sandbox."
|
||||
control={
|
||||
<Toggle
|
||||
label="Allow container spawning"
|
||||
checked={project.allow_docker_access}
|
||||
disabled={disabled}
|
||||
onChange={(v) => save({ allow_docker_access: v })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SwitchRow
|
||||
label="Mission Control"
|
||||
hint="A web dashboard for monitoring and managing Claude sessions remotely."
|
||||
control={
|
||||
<Toggle
|
||||
label="Mission Control"
|
||||
checked={project.mission_control_enabled}
|
||||
disabled={disabled}
|
||||
onChange={(v) => save({ mission_control_enabled: v })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{disabled && disabledReason && (
|
||||
<p className="text-xs text-[var(--text-disabled)]">{disabledReason}</p>
|
||||
)}
|
||||
</ConfigGroup>
|
||||
|
||||
<ConfigGroup
|
||||
title="Claude instructions"
|
||||
description="Written to ~/.claude/CLAUDE.md inside this project's container."
|
||||
>
|
||||
<ClaudeInstructionsEditor
|
||||
instructions={project.claude_instructions ?? ""}
|
||||
disabled={disabled}
|
||||
disabledReason={disabledReason}
|
||||
onSave={(value) => save({ claude_instructions: value || null })}
|
||||
/>
|
||||
</ConfigGroup>
|
||||
|
||||
<ConfigGroup
|
||||
title="Claude Code settings"
|
||||
description="Per-project CLI behaviour. These override the global defaults in Settings."
|
||||
>
|
||||
<ClaudeCodeSettingsEditor
|
||||
settings={project.claude_code_settings}
|
||||
disabled={disabled}
|
||||
disabledReason={disabledReason}
|
||||
onSave={(settings) => save({ claude_code_settings: settings })}
|
||||
/>
|
||||
</ConfigGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { Project, ProjectPath } from "../../../../lib/types";
|
||||
import Button from "../../../ui/Button";
|
||||
import Field, { ConfigGroup, inputClass, monoInputClass } from "../../../ui/Field";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
const [name, setName] = useState(project.name);
|
||||
const [paths, setPaths] = useState<ProjectPath[]>(project.paths ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
setName(project.name);
|
||||
setPaths(project.paths ?? []);
|
||||
}, [project]);
|
||||
|
||||
return (
|
||||
<ConfigGroup
|
||||
title="Workspace"
|
||||
description="What this sandbox is called and which host folders it can see."
|
||||
>
|
||||
<Field
|
||||
label="Project name"
|
||||
hint="Shown in the sidebar and on terminal tabs."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={() => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
setName(project.name);
|
||||
return;
|
||||
}
|
||||
if (trimmed !== project.name) save({ name: trimmed });
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
if (e.key === "Escape") setName(project.name);
|
||||
}}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<span className="block text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Folders
|
||||
</span>
|
||||
<p className="mt-0.5 mb-2 text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Each host folder is mounted at <span className="font-mono">/workspace/<name></span>{" "}
|
||||
inside the container.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{paths.map((pp, i) => (
|
||||
<div key={i} className="flex flex-col gap-1.5 sm:flex-row sm:items-center">
|
||||
<input
|
||||
value={pp.host_path}
|
||||
aria-label={`Folder ${i + 1} host path`}
|
||||
onChange={(e) => {
|
||||
const updated = [...paths];
|
||||
updated[i] = { ...updated[i], host_path: e.target.value };
|
||||
setPaths(updated);
|
||||
}}
|
||||
onBlur={() => save({ paths })}
|
||||
placeholder="/path/to/folder"
|
||||
disabled={disabled}
|
||||
className={`flex-1 min-w-0 ${inputClass}`}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
size="md"
|
||||
disabled={disabled}
|
||||
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);
|
||||
save({ paths: updated });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
<span className="text-xs text-[var(--text-secondary)] font-mono flex-shrink-0">
|
||||
/workspace/
|
||||
</span>
|
||||
<input
|
||||
value={pp.mount_name}
|
||||
aria-label={`Folder ${i + 1} mount name`}
|
||||
onChange={(e) => {
|
||||
const updated = [...paths];
|
||||
updated[i] = { ...updated[i], mount_name: e.target.value };
|
||||
setPaths(updated);
|
||||
}}
|
||||
onBlur={() => save({ paths })}
|
||||
placeholder="name"
|
||||
disabled={disabled}
|
||||
className={`w-40 ${monoInputClass}`}
|
||||
/>
|
||||
{paths.length > 1 && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="danger"
|
||||
disabled={disabled}
|
||||
aria-label={`Remove folder ${i + 1}`}
|
||||
onClick={() => {
|
||||
const updated = paths.filter((_, j) => j !== i);
|
||||
setPaths(updated);
|
||||
save({ paths: updated });
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="mt-2"
|
||||
disabled={disabled}
|
||||
onClick={() => setPaths([...paths, { host_path: "", mount_name: "" }])}
|
||||
>
|
||||
+ Add folder
|
||||
</Button>
|
||||
</div>
|
||||
</ConfigGroup>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user