Skip to content
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/lib/agent-launch-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ interface LinkedPresetLaunch {
commands: string[];
}

function areEnvMapsEqual(
export function areEnvMapsEqual(
left: Record<string, string>,
right: Record<string, string>,
): boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { Switch } from "@superset/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip";
import { useMutation } from "@tanstack/react-query";
import { Info, RotateCcw, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
getAgentCommandText,
isAgentCommandPatchChanged,
Expand All @@ -33,6 +33,7 @@ import { electronTrpc } from "renderer/lib/electron-trpc";
import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";
import { getHostServiceUnavailableMessage } from "renderer/lib/host-service-unavailable";
import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider";
import { AgentEnvEditor, type AgentEnvEditorHandle } from "../AgentEnvEditor";
import {
AgentDetailHeader,
AgentLaunchFields,
Expand Down Expand Up @@ -84,8 +85,12 @@ export function AgentDetail({
),
});

const envEditorRef = useRef<AgentEnvEditorHandle>(null);
const [envEditorKey, setEnvEditorKey] = useState(0);
const [label, setLabel] = useState(config.label);
const [commandText, setCommandText] = useState(getAgentCommandText(config));
const [commandText, setCommandText] = useState(
getAgentCommandText({ command: config.command, args: config.args }),
);
const [promptArgsText, setPromptArgsText] = useState(
joinArgs(config.promptArgs),
);
Expand All @@ -102,11 +107,7 @@ export function AgentDetail({
useEffect(() => {
setLabel(config.label);
setCommandText(
getAgentCommandText({
command: config.command,
args: config.args,
env: config.env,
}),
getAgentCommandText({ command: config.command, args: config.args }),
);
setPromptArgsText(joinArgs(config.promptArgs));
setResumeArgsText(joinArgs(config.resumeArgs ?? []));
Expand All @@ -116,7 +117,6 @@ export function AgentDetail({
config.label,
config.command,
config.args,
config.env,
config.promptArgs,
config.resumeArgs,
config.forkArgs,
Expand Down Expand Up @@ -194,6 +194,7 @@ export function AgentDetail({
},
onSuccess: (updated) => {
onChanged(updated);
setEnvEditorKey((key) => key + 1);
const updatedLabel = updated.label;
toast.success(
t({
Expand All @@ -219,22 +220,36 @@ export function AgentDetail({
};

const handleCommandBlur = () => {
const patch = parseAgentCommandText(commandText);
const { command } = patch;
const {
command,
args,
env: inlineEnv,
} = parseAgentCommandText(commandText);
if (command.length === 0) {
toast.error(
t({
message: "Command cannot be empty",
}),
);
setCommandText(getAgentCommandText(config));
setCommandText(
getAgentCommandText({ command: config.command, args: config.args }),
);
return;
}
if (isAgentCommandPatchChanged(config, patch)) {
updateMutation.mutate(patch);
if (Object.keys(inlineEnv).length > 0) {
envEditorRef.current?.assign(inlineEnv);
setCommandText(getAgentCommandText({ command, args }));
}
if (
isAgentCommandPatchChanged(config, { command, args, env: config.env })
) {
updateMutation.mutate({ command, args });
}
};

const handleEnvCommit = (env: Record<string, string>) =>
updateMutation.mutateAsync({ env });

const handlePromptArgsBlur = () => {
const args = parseArgs(promptArgsText);
const changed =
Expand Down Expand Up @@ -324,6 +339,20 @@ export function AgentDetail({
onPromptTransportChange={handleTransportChange}
/>

<Section
title={t({
message: "Environment Variables",
})}
>
<AgentEnvEditor
key={envEditorKey}
ref={envEditorRef}
idPrefix={config.id}
env={config.env}
onCommit={handleEnvCommit}
/>
</Section>

{isHooksSetupTarget ? (
<div className="pt-2">
<div className="flex items-center justify-between gap-8">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { Button } from "@superset/ui/button";
import { Input } from "@superset/ui/input";
import { Plus, X } from "lucide-react";
import {
type Ref,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { areEnvMapsEqual } from "renderer/lib/agent-launch-command";

const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;

interface EnvRow {
id: string;
key: string;
value: string;
saved: { key: string; value: string } | null;
}

function toRows(env: Record<string, string>): EnvRow[] {
return Object.entries(env).map(([key, value]) => ({
id: crypto.randomUUID(),
key,
value,
saved: { key, value },
}));
}

/** Rows that keep their saved name claim it before renamed or new rows. */
function acceptedRowIds(rows: readonly EnvRow[]): Set<string> {
const takenKeys = new Set<string>();
const ids = new Set<string>();
const accept = (row: EnvRow) => {
const key = row.key.trim();
if (ids.has(row.id) || !ENV_KEY.test(key) || takenKeys.has(key)) return;
takenKeys.add(key);
ids.add(row.id);
};
for (const row of rows) if (row.saved?.key === row.key.trim()) accept(row);
for (const row of rows) accept(row);
return ids;
}

/** Rows whose name is not accepted keep their last saved entry. */
function toEnv(rows: readonly EnvRow[]): Record<string, string> {
const accepted = acceptedRowIds(rows);
const entries = new Map<string, string>();
for (const row of rows) {
if (accepted.has(row.id)) entries.set(row.key.trim(), row.value);
}
for (const row of rows) {
if (!accepted.has(row.id) && row.saved && !entries.has(row.saved.key)) {
entries.set(row.saved.key, row.saved.value);
}
}
return Object.fromEntries(entries);
}

function markSaved(rows: EnvRow[], submitted: readonly EnvRow[]): EnvRow[] {
const accepted = acceptedRowIds(submitted);
return rows.map((row) => {
const sent = submitted.find((candidate) => candidate.id === row.id);
return sent && accepted.has(row.id)
? { ...row, saved: { key: sent.key.trim(), value: sent.value } }
: row;
});
}

function withAssignments(
rows: readonly EnvRow[],
assignments: Record<string, string>,
): EnvRow[] {
const remaining = new Map(Object.entries(assignments));
const accepted = acceptedRowIds(rows);
const updated = rows.map((row) => {
const key = row.key.trim();
if (!accepted.has(row.id) || !remaining.has(key)) return row;
const value = remaining.get(key) ?? "";
remaining.delete(key);
return { ...row, value };
Comment thread
lacherogwu marked this conversation as resolved.
Resolved
});
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
const added = [...remaining].map(([key, value]) => ({
id: crypto.randomUUID(),
key,
value,
saved: null,
}));
return [...updated, ...added];
}

function hasUnsavedEdits(rows: readonly EnvRow[]): boolean {
return rows.some((row) =>
row.saved === null
? row.key.trim().length > 0 || row.value.length > 0
: row.key.trim() !== row.saved.key || row.value !== row.saved.value,
);
}

export interface AgentEnvEditorHandle {
/** Sets variables, e.g. inline `VAR=value` prefixes from the command field. */
assign: (assignments: Record<string, string>) => void;
}

interface AgentEnvEditorProps {
idPrefix: string;
env: Record<string, string>;
onCommit: (env: Record<string, string>) => Promise<unknown>;
ref?: Ref<AgentEnvEditorHandle>;
}

export function AgentEnvEditor({
idPrefix,
env,
onCommit,
ref,
}: AgentEnvEditorProps) {
const { t } = useLingui();
const variableNameLabel = t({ message: "Variable name" });
const variableValueLabel = t({ message: "Variable value" });
const [rows, setRows] = useState(() => toRows(env));
const rowsRef = useRef(rows);
rowsRef.current = rows;
const saves = useRef({ inFlight: false, queued: false, lastSent: env });
const accepted = acceptedRowIds(rows);

// While a save is pending or rows hold unsaved edits, the rows are the
// source of truth: a full-config response from another save can carry an
// older env.
useEffect(() => {
const state = saves.current;
if (state.inFlight || state.queued) return;
if (hasUnsavedEdits(rows)) return;
if (areEnvMapsEqual(env, state.lastSent)) return;
state.lastSent = env;
setRows(toRows(env));
}, [env, rows]);

const send = (submitted: readonly EnvRow[]) => {
const state = saves.current;
const nextEnv = toEnv(submitted);
if (areEnvMapsEqual(nextEnv, state.lastSent)) {
setRows((current) => markSaved(current, submitted));
return;
}
const previous = state.lastSent;
state.inFlight = true;
state.lastSent = nextEnv;
onCommit(nextEnv)
.then(
() => setRows((current) => markSaved(current, submitted)),
() => {
state.lastSent = previous;
},
)
.finally(() => {
state.inFlight = false;
if (state.queued) {
state.queued = false;
send(rowsRef.current);
}
});
};

const commit = (next: readonly EnvRow[]) => {
if (saves.current.inFlight) saves.current.queued = true;
else send(next);
};

useImperativeHandle(ref, () => ({
assign: (assignments) => {
const next = withAssignments(rowsRef.current, assignments);
rowsRef.current = next;
setRows(next);
commit(next);
},
}));

const isInvalid = (row: EnvRow) =>
!accepted.has(row.id) && (row.key.trim().length > 0 || row.saved !== null);

const updateRow = (id: string, patch: Partial<EnvRow>) =>
setRows((current) =>
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

const removeRow = (id: string) => {
const next = rows.filter((row) => row.id !== id);
rowsRef.current = next;
setRows(next);
commit(next);
};

const addRow = () =>
setRows((current) => [
...current,
{ id: crypto.randomUUID(), key: "", value: "", saved: null },
]);

return (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
<Trans>
Set when this agent launches, e.g. <code>CLAUDE_CONFIG_DIR</code> to
use another Claude login.
</Trans>
</p>
{rows.length === 0 ? (
<p className="rounded-md border border-border/70 px-3 py-2 text-xs text-muted-foreground">
<Trans>No variables set yet.</Trans>
</p>
) : (
rows.map((row) => (
<div key={row.id} className="flex items-center gap-2">
<Input
id={`${idPrefix}-env-key-${row.id}`}
className="w-2/5 font-mono text-xs"
value={row.key}
onChange={(e) => updateRow(row.id, { key: e.target.value })}
onBlur={() => commit(rows)}
aria-label={variableNameLabel}
aria-invalid={isInvalid(row)}
placeholder={variableNameLabel}
spellCheck={false}
/>
<Input
id={`${idPrefix}-env-value-${row.id}`}
className="flex-1 font-mono text-xs"
value={row.value}
onChange={(e) => updateRow(row.id, { value: e.target.value })}
onBlur={() => commit(rows)}
aria-label={variableValueLabel}
placeholder={variableValueLabel}
spellCheck={false}
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => removeRow(row.id)}
aria-label={t({ message: "Remove variable" })}
>
<X className="size-3.5" />
</Button>
</div>
))
)}
<Button
type="button"
variant="outline"
size="xs"
onClick={addRow}
className="gap-1.5"
>
<Plus className="size-3.5" />
<Trans>Add variable</Trans>
</Button>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AgentEnvEditor, type AgentEnvEditorHandle } from "./AgentEnvEditor";
Loading
Loading