Simplify create agent setup flow (#78)

This commit is contained in:
Wes
2026-03-16 09:03:43 -07:00
committed by GitHub
parent 3814722c36
commit d7b45125e9
12 changed files with 463 additions and 299 deletions

View File

@@ -15,7 +15,10 @@ use crate::{
RelayAgentInfo, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_ARG, DEFAULT_AGENT_COMMAND,
DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND,
},
relay::{build_authed_request, managed_agent_owner_pubkey, relay_ws_url, send_json_request},
relay::{
build_authed_request, managed_agent_owner_pubkey, relay_ws_url, send_json_request,
sync_managed_agent_profile_display_name,
},
util::now_iso,
};
@@ -98,145 +101,176 @@ pub async fn create_managed_agent(
None
};
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let mut records = load_managed_agents(&app)?;
let mut runtimes = state
.managed_agent_processes
.lock()
.map_err(|error| error.to_string())?;
let (agent, private_key_nsec, api_token, pubkey, resolved_relay_url, spawn_error, token_scopes) =
{
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let mut records = load_managed_agents(&app)?;
let mut runtimes = state
.managed_agent_processes
.lock()
.map_err(|error| error.to_string())?;
if sync_managed_agent_processes(&mut records, &mut runtimes) {
save_managed_agents(&app, &records)?;
}
if sync_managed_agent_processes(&mut records, &mut runtimes) {
save_managed_agents(&app, &records)?;
}
let keys = Keys::generate();
let pubkey = keys.public_key().to_hex();
if records.iter().any(|record| record.pubkey == pubkey) {
return Err(format!("agent {pubkey} already exists"));
}
let keys = Keys::generate();
let pubkey = keys.public_key().to_hex();
if records.iter().any(|record| record.pubkey == pubkey) {
return Err(format!("agent {pubkey} already exists"));
}
let private_key_nsec = keys
.secret_key()
.to_bech32()
.map_err(|error| format!("failed to encode private key: {error}"))?;
let token_scopes = if input.mint_token {
let requested = input
.token_scopes
.into_iter()
.map(|scope| scope.trim().to_string())
.filter(|scope| !scope.is_empty())
.collect::<Vec<_>>();
if requested.is_empty() {
default_token_scopes()
} else {
requested
}
} else {
Vec::new()
};
let minted = if input.mint_token {
let token_name = input
.token_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(name);
Some(run_sprout_admin_mint_token(
&app,
&pubkey,
owner_pubkey
let private_key_nsec = keys
.secret_key()
.to_bech32()
.map_err(|error| format!("failed to encode private key: {error}"))?;
let token_scopes = if input.mint_token {
let requested = input
.token_scopes
.into_iter()
.map(|scope| scope.trim().to_string())
.filter(|scope| !scope.is_empty())
.collect::<Vec<_>>();
if requested.is_empty() {
default_token_scopes()
} else {
requested
}
} else {
Vec::new()
};
let api_token = if input.mint_token {
let token_name = input
.token_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(name);
Some(
run_sprout_admin_mint_token(
&app,
&pubkey,
owner_pubkey.as_deref().ok_or_else(|| {
"managed agent owner pubkey was not resolved".to_string()
})?,
token_name,
&token_scopes,
)?
.api_token,
)
} else {
None
};
let resolved_relay_url = input
.relay_url
.as_deref()
.ok_or_else(|| "managed agent owner pubkey was not resolved".to_string())?,
token_name,
&token_scopes,
)?)
} else {
None
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(relay_ws_url);
let mut record = crate::managed_agents::ManagedAgentRecord {
pubkey: pubkey.clone(),
name: name.to_string(),
private_key_nsec: private_key_nsec.clone(),
api_token: api_token.clone(),
relay_url: resolved_relay_url.clone(),
acp_command: input
.acp_command
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_ACP_COMMAND)
.to_string(),
agent_command: input
.agent_command
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_AGENT_COMMAND)
.to_string(),
agent_args: input
.agent_args
.into_iter()
.map(|arg| arg.trim().to_string())
.filter(|arg| !arg.is_empty())
.collect::<Vec<_>>(),
mcp_command: input
.mcp_command
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_MCP_COMMAND)
.to_string(),
turn_timeout_seconds: input
.turn_timeout_seconds
.filter(|seconds| *seconds > 0)
.unwrap_or(DEFAULT_AGENT_TURN_TIMEOUT_SECONDS),
created_at: now_iso(),
updated_at: now_iso(),
last_started_at: None,
last_stopped_at: None,
last_exit_code: None,
last_error: None,
};
if record.agent_args.is_empty() {
record.agent_args.push(DEFAULT_AGENT_ARG.to_string());
}
records.push(record);
let mut spawn_error = None;
if input.spawn_after_create {
let record = find_managed_agent_mut(&mut records, &pubkey)?;
if let Err(error) = start_managed_agent_process(&app, record, &mut runtimes) {
record.updated_at = now_iso();
record.last_error = Some(error.clone());
spawn_error = Some(error);
}
}
save_managed_agents(&app, &records)?;
let record = records
.iter()
.find(|record| record.pubkey == pubkey)
.ok_or_else(|| "created agent disappeared unexpectedly".to_string())?;
let agent = build_managed_agent_summary(&app, record, &runtimes)?;
Ok::<_, String>((
agent,
private_key_nsec,
api_token,
pubkey,
resolved_relay_url,
spawn_error,
token_scopes,
))
}?;
let profile_sync_error = match sync_managed_agent_profile_display_name(
&state,
&resolved_relay_url,
&pubkey,
api_token.as_deref(),
&token_scopes,
name,
)
.await
{
Ok(()) => None,
Err(error) => Some(error),
};
let resolved_relay_url = input
.relay_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(relay_ws_url);
let mut record = crate::managed_agents::ManagedAgentRecord {
pubkey: pubkey.clone(),
name: name.to_string(),
private_key_nsec: private_key_nsec.clone(),
api_token: minted.as_ref().map(|output| output.api_token.clone()),
relay_url: resolved_relay_url,
acp_command: input
.acp_command
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_ACP_COMMAND)
.to_string(),
agent_command: input
.agent_command
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_AGENT_COMMAND)
.to_string(),
agent_args: input
.agent_args
.into_iter()
.map(|arg| arg.trim().to_string())
.filter(|arg| !arg.is_empty())
.collect::<Vec<_>>(),
mcp_command: input
.mcp_command
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_MCP_COMMAND)
.to_string(),
turn_timeout_seconds: input
.turn_timeout_seconds
.filter(|seconds| *seconds > 0)
.unwrap_or(DEFAULT_AGENT_TURN_TIMEOUT_SECONDS),
created_at: now_iso(),
updated_at: now_iso(),
last_started_at: None,
last_stopped_at: None,
last_exit_code: None,
last_error: None,
};
if record.agent_args.is_empty() {
record.agent_args.push(DEFAULT_AGENT_ARG.to_string());
}
records.push(record);
let mut spawn_error = None;
if input.spawn_after_create {
let record = find_managed_agent_mut(&mut records, &pubkey)?;
if let Err(error) = start_managed_agent_process(&app, record, &mut runtimes) {
record.updated_at = now_iso();
record.last_error = Some(error.clone());
spawn_error = Some(error);
}
}
save_managed_agents(&app, &records)?;
let record = records
.iter()
.find(|record| record.pubkey == pubkey)
.ok_or_else(|| "created agent disappeared unexpectedly".to_string())?;
let agent = build_managed_agent_summary(&app, record, &runtimes)?;
Ok(CreateManagedAgentResponse {
agent,
private_key_nsec,
api_token: minted.map(|output| output.api_token),
api_token,
profile_sync_error,
spawn_error,
})
}

View File

@@ -85,6 +85,7 @@ pub struct CreateManagedAgentResponse {
pub agent: ManagedAgentSummary,
pub private_key_nsec: String,
pub api_token: Option<String>,
pub profile_sync_error: Option<String>,
pub spawn_error: Option<String>,
}

View File

@@ -4,20 +4,35 @@ use reqwest::Method;
use serde::de::DeserializeOwned;
use sha2::{Digest, Sha256};
use crate::{app_state::AppState, models::ProfileInfo};
use crate::{
app_state::AppState,
models::{ProfileInfo, UpdateProfileBody},
};
pub fn relay_ws_url() -> String {
std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
}
pub fn relay_api_base_url() -> String {
if let Ok(base) = std::env::var("SPROUT_RELAY_HTTP") {
return base;
pub fn relay_http_base_url(relay_url: &str) -> String {
let trimmed = relay_url.trim().trim_end_matches('/');
if let Some(suffix) = trimmed.strip_prefix("wss://") {
return format!("https://{suffix}");
}
relay_ws_url()
.replace("wss://", "https://")
.replace("ws://", "http://")
if let Some(suffix) = trimmed.strip_prefix("ws://") {
return format!("http://{suffix}");
}
trimmed.to_string()
}
pub fn relay_api_base_url() -> String {
if let Ok(base) = std::env::var("SPROUT_RELAY_HTTP") {
return base.trim().trim_end_matches('/').to_string();
}
relay_http_base_url(&relay_ws_url())
}
pub fn build_authed_request(
@@ -62,6 +77,54 @@ pub async fn managed_agent_owner_pubkey(state: &AppState) -> Result<String, Stri
Ok(profile.pubkey)
}
fn token_supports_scope(scopes: &[String], required_scope: &str) -> bool {
scopes.iter().any(|scope| scope == required_scope)
}
pub async fn sync_managed_agent_profile_display_name(
state: &AppState,
relay_url: &str,
pubkey: &str,
api_token: Option<&str>,
token_scopes: &[String],
display_name: &str,
) -> Result<(), String> {
let url = format!(
"{}{}",
relay_http_base_url(relay_url),
"/api/users/me/profile"
);
let use_bearer_token = api_token.is_some() && token_supports_scope(token_scopes, "users:write");
let mut request = state.http_client.request(Method::PUT, url);
if let Some(token) = api_token.filter(|_| use_bearer_token) {
request = request.header("Authorization", format!("Bearer {token}"));
} else {
request = request.header("X-Pubkey", pubkey);
}
let request = request.json(&UpdateProfileBody {
display_name: Some(display_name),
avatar_url: None,
about: None,
nip05_handle: None,
});
send_empty_request(request).await.map_err(|error| {
if api_token.is_some() && !use_bearer_token {
format!(
"Created the agent, but could not sync its profile display name. The minted token does not include `users:write`, and the relay rejected dev-mode pubkey auth: {error}"
)
} else if api_token.is_some() {
format!("Created the agent, but could not sync its profile display name: {error}")
} else {
format!(
"Created the agent, but could not sync its profile display name without a token: {error}"
)
}
})
}
fn session_api_token(state: &AppState) -> Result<Option<String>, String> {
let token = state
.session_token

View File

@@ -1,3 +1,4 @@
import { ChevronDown } from "lucide-react";
import * as React from "react";
import {
@@ -5,6 +6,7 @@ import {
useCreateManagedAgentMutation,
useManagedAgentPrereqsQuery,
} from "@/features/agents/hooks";
import { DEFAULT_MANAGED_AGENT_SCOPES } from "@/features/tokens/lib/scopeOptions";
import type {
CreateManagedAgentInput,
CreateManagedAgentResponse,
@@ -22,6 +24,7 @@ import {
CreateAgentBasicsFields,
CreateAgentOptionToggles,
CreateAgentPrerequisitesCard,
CreateAgentRuntimeProviderField,
CreateAgentRuntimeFields,
CreateAgentTokenSection,
} from "./CreateAgentDialogSections";
@@ -48,20 +51,14 @@ export function CreateAgentDialog({
const [spawnAfterCreate, setSpawnAfterCreate] = React.useState(true);
const [tokenName, setTokenName] = React.useState("");
const [selectedScopes, setSelectedScopes] = React.useState<Set<TokenScope>>(
() =>
new Set<TokenScope>([
"messages:read",
"messages:write",
"channels:read",
"users:read",
"users:write",
]),
() => new Set<TokenScope>(DEFAULT_MANAGED_AGENT_SCOPES),
);
const [turnTimeoutSeconds, setTurnTimeoutSeconds] = React.useState("300");
const [selectedProviderId, setSelectedProviderId] =
React.useState<string>("custom");
const [hasSyncedProviderSelection, setHasSyncedProviderSelection] =
React.useState(false);
const [showAdvanced, setShowAdvanced] = React.useState(false);
const providers = providersQuery.data ?? [];
const prereqs = prereqsQuery.data ?? null;
const selectedProvider = React.useMemo(
@@ -137,21 +134,22 @@ export function CreateAgentDialog({
setSpawnAfterCreate(false);
}, [prereqs, spawnAfterCreate]);
React.useEffect(() => {
if (
providersQuery.error instanceof Error ||
prereqsQuery.error instanceof Error
) {
setShowAdvanced(true);
}
}, [prereqsQuery.error, providersQuery.error]);
function reset() {
setName("");
setRelayUrl("");
setMintToken(true);
setSpawnAfterCreate(true);
setTokenName("");
setSelectedScopes(
new Set<TokenScope>([
"messages:read",
"messages:write",
"channels:read",
"users:read",
"users:write",
]),
);
setSelectedScopes(new Set<TokenScope>(DEFAULT_MANAGED_AGENT_SCOPES));
setAcpCommand("sprout-acp");
setAgentCommand("goose");
setAgentArgs("acp");
@@ -159,6 +157,7 @@ export function CreateAgentDialog({
setTurnTimeoutSeconds("300");
setSelectedProviderId("custom");
setHasSyncedProviderSelection(false);
setShowAdvanced(false);
createMutation.reset();
}
@@ -186,6 +185,7 @@ export function CreateAgentDialog({
setSelectedProviderId(nextProviderId);
if (nextProviderId === "custom") {
setShowAdvanced(true);
return;
}
@@ -244,55 +244,23 @@ export function CreateAgentDialog({
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Create agent</DialogTitle>
<DialogDescription>
This creates a local agent identity, optionally mints a relay
token, and can spawn `sprout-acp` immediately.
This creates a local agent identity, syncs its display name when
possible, optionally mints a relay token, and can spawn
`sprout-acp` immediately.
</DialogDescription>
</DialogHeader>
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-5">
<CreateAgentBasicsFields
name={name}
onNameChange={setName}
onRelayUrlChange={setRelayUrl}
relayUrl={relayUrl}
/>
<CreateAgentBasicsFields name={name} onNameChange={setName} />
<CreateAgentRuntimeFields
acpCommand={acpCommand}
agentArgs={agentArgs}
agentCommand={agentCommand}
mcpCommand={mcpCommand}
onAcpCommandChange={setAcpCommand}
onAgentArgsChange={setAgentArgs}
onAgentCommandChange={setAgentCommand}
onMcpCommandChange={setMcpCommand}
<CreateAgentRuntimeProviderField
onProviderChange={handleProviderChange}
onTurnTimeoutChange={setTurnTimeoutSeconds}
providers={providers}
providersLoading={providersQuery.isLoading}
selectedProvider={selectedProvider}
selectedProviderId={selectedProviderId}
turnTimeoutSeconds={turnTimeoutSeconds}
/>
<CreateAgentPrerequisitesCard
isLoading={prereqsQuery.isLoading}
prereqs={prereqs}
prerequisiteCards={prerequisiteCards}
/>
{providersQuery.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{providersQuery.error.message}
</p>
) : null}
{prereqsQuery.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{prereqsQuery.error.message}
</p>
) : null}
<CreateAgentOptionToggles
isMintSupported={isMintSupported}
isSpawnSupported={isSpawnSupported}
@@ -322,6 +290,70 @@ export function CreateAgentDialog({
/>
) : null}
<div className="rounded-2xl border border-border/70 bg-muted/20">
<button
aria-expanded={showAdvanced}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
onClick={() => setShowAdvanced((current) => !current)}
type="button"
>
<div>
<p className="text-sm font-semibold tracking-tight">
Advanced setup
</p>
<p className="text-sm text-muted-foreground">
Relay overrides, raw commands, timeout, and local binary
checks.
</p>
</div>
<span className="shrink-0 self-center text-muted-foreground">
<ChevronDown
className={`h-4 w-4 transition-transform ${showAdvanced ? "rotate-180" : ""}`}
/>
</span>
</button>
{showAdvanced ? (
<div className="overflow-hidden">
<div className="space-y-5 border-t border-border/60 px-4 py-4">
<CreateAgentRuntimeFields
acpCommand={acpCommand}
agentArgs={agentArgs}
agentCommand={agentCommand}
mcpCommand={mcpCommand}
onAcpCommandChange={setAcpCommand}
onAgentArgsChange={setAgentArgs}
onAgentCommandChange={setAgentCommand}
onMcpCommandChange={setMcpCommand}
onRelayUrlChange={setRelayUrl}
onTurnTimeoutChange={setTurnTimeoutSeconds}
relayUrl={relayUrl}
selectedProviderId={selectedProviderId}
turnTimeoutSeconds={turnTimeoutSeconds}
/>
<CreateAgentPrerequisitesCard
isLoading={prereqsQuery.isLoading}
prereqs={prereqs}
prerequisiteCards={prerequisiteCards}
/>
{providersQuery.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{providersQuery.error.message}
</p>
) : null}
{prereqsQuery.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{prereqsQuery.error.message}
</p>
) : null}
</div>
</div>
) : null}
</div>
{createMutation.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{createMutation.error.message}

View File

@@ -4,9 +4,10 @@ import type {
ManagedAgentPrereqs,
TokenScope,
} from "@/shared/api/types";
import { MANAGED_AGENT_SCOPE_OPTIONS } from "@/features/tokens/lib/scopeOptions";
import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import { AGENT_SCOPE_OPTIONS, describeResolvedCommand } from "./agentUi";
import { describeResolvedCommand } from "./agentUi";
export type PrerequisiteCard = {
id: string;
@@ -17,41 +18,85 @@ export type PrerequisiteCard = {
export function CreateAgentBasicsFields({
name,
relayUrl,
onNameChange,
onRelayUrlChange,
}: {
name: string;
relayUrl: string;
onNameChange: (value: string) => void;
onRelayUrlChange: (value: string) => void;
}) {
return (
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="agent-name">
Name
</label>
<Input
data-testid="agent-name-input"
id="agent-name"
onChange={(event) => onNameChange(event.target.value)}
placeholder="alice"
value={name}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="agent-name">
Agent name
</label>
<Input
autoCapitalize="none"
autoCorrect="off"
data-testid="agent-name-input"
id="agent-name"
onChange={(event) => onNameChange(event.target.value)}
placeholder="Support bot"
spellCheck={false}
value={name}
/>
<p className="text-xs text-muted-foreground">
Used as the local label and synced to the agent profile display name
when the relay accepts the create-time auth.
</p>
</div>
);
}
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="agent-relay-url">
Relay URL
</label>
<Input
id="agent-relay-url"
onChange={(event) => onRelayUrlChange(event.target.value)}
placeholder="Leave blank to use the desktop relay"
value={relayUrl}
/>
</div>
export function CreateAgentRuntimeProviderField({
providers,
providersLoading,
selectedProvider,
selectedProviderId,
onProviderChange,
}: {
providers: AcpProvider[];
providersLoading: boolean;
selectedProvider: AcpProvider | null;
selectedProviderId: string;
onProviderChange: (value: string) => void;
}) {
return (
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="agent-provider">
Agent runtime
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
id="agent-provider"
onChange={(event) => onProviderChange(event.target.value)}
value={selectedProviderId}
>
{providers.map((provider) => (
<option key={provider.id} value={provider.id}>
{provider.label}
</option>
))}
<option value="custom">Custom command</option>
</select>
{selectedProvider ? (
<p className="text-xs text-muted-foreground">
Detected via{" "}
<span className="font-medium">
{describeResolvedCommand(
selectedProvider.command,
selectedProvider.binaryPath,
)}
</span>
</p>
) : providersLoading ? (
<p className="text-xs text-muted-foreground">
Looking for installed ACP runtimes...
</p>
) : (
<p className="text-xs text-muted-foreground">
No known ACP runtime was detected. You can still enter a custom
command in Advanced setup.
</p>
)}
</div>
);
}
@@ -61,37 +106,45 @@ export function CreateAgentRuntimeFields({
agentArgs,
agentCommand,
mcpCommand,
providers,
providersLoading,
selectedProvider,
relayUrl,
selectedProviderId,
turnTimeoutSeconds,
onAcpCommandChange,
onAgentArgsChange,
onAgentCommandChange,
onMcpCommandChange,
onProviderChange,
onRelayUrlChange,
onTurnTimeoutChange,
}: {
acpCommand: string;
agentArgs: string;
agentCommand: string;
mcpCommand: string;
providers: AcpProvider[];
providersLoading: boolean;
selectedProvider: AcpProvider | null;
relayUrl: string;
selectedProviderId: string;
turnTimeoutSeconds: string;
onAcpCommandChange: (value: string) => void;
onAgentArgsChange: (value: string) => void;
onAgentCommandChange: (value: string) => void;
onMcpCommandChange: (value: string) => void;
onProviderChange: (value: string) => void;
onRelayUrlChange: (value: string) => void;
onTurnTimeoutChange: (value: string) => void;
}) {
return (
<>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="agent-relay-url">
Relay URL
</label>
<Input
id="agent-relay-url"
onChange={(event) => onRelayUrlChange(event.target.value)}
placeholder="Leave blank to use the desktop relay"
value={relayUrl}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="agent-acp-command">
ACP command
@@ -102,45 +155,6 @@ export function CreateAgentRuntimeFields({
value={acpCommand}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="agent-provider">
Agent runtime
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
id="agent-provider"
onChange={(event) => onProviderChange(event.target.value)}
value={selectedProviderId}
>
{providers.map((provider) => (
<option key={provider.id} value={provider.id}>
{provider.label}
</option>
))}
<option value="custom">Custom command</option>
</select>
{selectedProvider ? (
<p className="text-xs text-muted-foreground">
Detected via{" "}
<span className="font-medium">
{describeResolvedCommand(
selectedProvider.command,
selectedProvider.binaryPath,
)}
</span>
</p>
) : providersLoading ? (
<p className="text-xs text-muted-foreground">
Looking for installed ACP runtimes...
</p>
) : (
<p className="text-xs text-muted-foreground">
No known ACP runtime was detected. You can still enter a custom
command below.
</p>
)}
</div>
</div>
{selectedProviderId === "custom" ? (
@@ -365,17 +379,17 @@ export function CreateAgentTokenSection({
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Token scopes</p>
<div className="grid gap-2 md:grid-cols-3">
{AGENT_SCOPE_OPTIONS.map((scope) => {
<p className="text-sm font-medium">Scopes</p>
<div className="grid grid-cols-2 gap-2">
{MANAGED_AGENT_SCOPE_OPTIONS.map((scope) => {
const selected = selectedScopes.has(scope.value);
return (
<button
className={cn(
"rounded-xl border px-3 py-2 text-left text-sm transition-colors",
"rounded-lg border px-3 py-2 text-left text-sm transition-colors",
selected
? "border-primary bg-primary/10"
: "border-border/70 bg-background/80 hover:bg-accent",
? "border-primary bg-primary/10 text-foreground"
: "border-border/60 text-muted-foreground hover:bg-accent",
)}
key={scope.value}
onClick={() => onScopeToggle(scope.value)}

View File

@@ -71,6 +71,12 @@ export function SecretRevealDialog({
</div>
) : null}
{created.profileSyncError ? (
<p className="rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
{created.profileSyncError}
</p>
) : null}
{created.spawnError ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{created.spawnError}

View File

@@ -1,17 +1,3 @@
import type { TokenScope } from "@/shared/api/types";
export const AGENT_SCOPE_OPTIONS: Array<{ value: TokenScope; label: string }> =
[
{ value: "messages:read", label: "Messages read" },
{ value: "messages:write", label: "Messages write" },
{ value: "channels:read", label: "Channels read" },
{ value: "channels:write", label: "Channels write" },
{ value: "users:read", label: "Users read" },
{ value: "users:write", label: "Users write" },
{ value: "files:read", label: "Files read" },
{ value: "files:write", label: "Files write" },
];
export function formatTimestamp(value: string | null) {
if (!value) {
return "Never";

View File

@@ -226,12 +226,15 @@ function StreamsSection({
>
<Input
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
className="h-8 bg-background/80"
data-testid="create-stream-name"
disabled={isCreatingChannel}
onChange={(event) => onChangeName(event.target.value)}
placeholder="release-notes"
ref={createInputRef}
spellCheck={false}
value={draftName}
/>
<Input

View File

@@ -0,0 +1,31 @@
import type { TokenScope } from "@/shared/api/types";
export const TOKEN_SCOPE_OPTIONS: Array<{
value: TokenScope;
label: string;
}> = [
{ value: "messages:read", label: "Messages: Read" },
{ value: "messages:write", label: "Messages: Write" },
{ value: "channels:read", label: "Channels: Read" },
{ value: "channels:write", label: "Channels: Write" },
{ value: "users:read", label: "Users: Read" },
{ value: "files:read", label: "Files: Read" },
{ value: "files:write", label: "Files: Write" },
];
export const MANAGED_AGENT_SCOPE_OPTIONS: Array<{
value: TokenScope;
label: string;
}> = [
...TOKEN_SCOPE_OPTIONS.slice(0, 5),
{ value: "users:write", label: "Users: Write" },
...TOKEN_SCOPE_OPTIONS.slice(5),
];
export const DEFAULT_MANAGED_AGENT_SCOPES: TokenScope[] = [
"messages:read",
"messages:write",
"channels:read",
"users:read",
"users:write",
];

View File

@@ -16,6 +16,7 @@ import {
useRevokeTokenMutation,
useTokensQuery,
} from "@/features/tokens/hooks";
import { TOKEN_SCOPE_OPTIONS } from "@/features/tokens/lib/scopeOptions";
import { getChannelMembers } from "@/shared/api/tauri";
import type { Channel, Token, TokenScope } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
@@ -29,16 +30,6 @@ import {
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
const ALL_SCOPES: { value: TokenScope; label: string }[] = [
{ value: "messages:read", label: "Messages: Read" },
{ value: "messages:write", label: "Messages: Write" },
{ value: "channels:read", label: "Channels: Read" },
{ value: "channels:write", label: "Channels: Write" },
{ value: "users:read", label: "Users: Read" },
{ value: "files:read", label: "Files: Read" },
{ value: "files:write", label: "Files: Write" },
];
const EXPIRY_OPTIONS = [
{ value: 7, label: "7 days" },
{ value: 30, label: "30 days" },
@@ -375,7 +366,7 @@ function CreateTokenDialog({
<div className="space-y-1.5">
<p className="text-sm font-medium">Scopes</p>
<div className="grid grid-cols-2 gap-2">
{ALL_SCOPES.map(({ value, label }) => {
{TOKEN_SCOPE_OPTIONS.map(({ value, label }) => {
const isSelected = selectedScopes.has(value);
return (
<button

View File

@@ -230,6 +230,7 @@ type RawCreateManagedAgentResponse = {
agent: RawManagedAgent;
private_key_nsec: string;
api_token: string | null;
profile_sync_error: string | null;
spawn_error: string | null;
};
@@ -815,6 +816,7 @@ export async function createManagedAgent(
agent: fromRawManagedAgent(response.agent),
privateKeyNsec: response.private_key_nsec,
apiToken: response.api_token,
profileSyncError: response.profile_sync_error,
spawnError: response.spawn_error,
};
}

View File

@@ -283,6 +283,7 @@ export type CreateManagedAgentResponse = {
agent: ManagedAgent;
privateKeyNsec: string;
apiToken: string | null;
profileSyncError: string | null;
spawnError: string | null;
};