feat(dashboard): enrich profiles dashboard and de-dupe channel env vars (#37872)
* feat(desktop): enrich profiles dashboard and de-dupe channel env vars Add active-profile switching, role descriptions (manual + auto-generate via the auxiliary LLM), per-profile model selection, and gateway-running / distribution badges to the GUI Profiles page. New profile creation gains clone-all, optional description and model assignment. Hide messaging-platform credentials (channel_managed) from the Keys/Env page since the Channels page is the canonical surface for them, and relabel the trimmed "messaging" category as "Gateway". Co-authored-by: Cursor <cursoragent@cursor.com> * fix(desktop): address review feedback on profiles/env changes - ProfilesPage: scope the action-menu outside-click handler to the menu's own container via a ref so opening one card's menu no longer leaves others open. - EnvPage: route the "Gateway" label and hint through i18n (t.common.gateway / gatewayHint) instead of hard-coded English, with an English fallback for untranslated locales. - web_server: only report description_auto=true when auto-generation actually succeeded. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(desktop): address second-round review on profiles - ProfilesPage: treat describe-auto success by null-checking the description and trust the response's description_auto flag instead of assuming true; disable the model-editor Save button unless the selected choice resolves to a real /api/model/options entry (avoids silent no-op saves). - tests: cover the new profile endpoints (active get/set + 404, description round-trip + 404, model round-trip + 400 validation, and describe-auto success/failure contracts). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(desktop): more profiles review fixes (toggles, races, tests) - ProfilesPage: use the canonical `active` returned by setActiveProfile; make the SOUL/description/model action-menu items toggle their editor closed when already open; guard description save/auto-describe against stale responses via an activeDescRequest ref so a late reply can't clobber a different open editor. - tests: assert /api/env channel_managed classification matches _channel_managed_env_keys(). Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -43,6 +43,9 @@ export const en: Translations = {
|
||||
expand: "Expand",
|
||||
general: "General",
|
||||
messaging: "Messaging",
|
||||
gateway: "Gateway",
|
||||
gatewayHint:
|
||||
"Messaging platforms, the API server and webhooks are configured on the Channels page. These are gateway-wide settings (proxy/relay mode and the global allowlist).",
|
||||
pluginLoadFailed:
|
||||
"Could not load this plugin’s script. Check the Network tab (dashboard-plugins/…) and the server’s plugin path.",
|
||||
pluginNotRegistered:
|
||||
@ -309,6 +312,38 @@ export const en: Translations = {
|
||||
created: "Created",
|
||||
deleted: "Deleted",
|
||||
renamed: "Renamed",
|
||||
activeProfile: "Active profile",
|
||||
activeBadge: "active",
|
||||
setActive: "Set as active",
|
||||
activeSet: "Active profile set",
|
||||
gatewayRunning: "Gateway running",
|
||||
gatewayStopped: "Gateway stopped",
|
||||
gatewayRunningWarning:
|
||||
"This profile's gateway is running — it will be stopped.",
|
||||
aliasBadge: "alias",
|
||||
description: "Description",
|
||||
descriptionPlaceholder:
|
||||
"What is this profile good at? Used to route kanban tasks by role.",
|
||||
noDescription: "No description",
|
||||
editDescription: "Edit description",
|
||||
descriptionSaved: "Description saved",
|
||||
reviewBadge: "review",
|
||||
autoGenerate: "Auto-generate",
|
||||
generating: "Generating…",
|
||||
describeFailed: "Could not generate description",
|
||||
distribution: "Distribution",
|
||||
advancedOptions: "Advanced options",
|
||||
cloneAll: "Clone everything (memories, sessions, skills, state)",
|
||||
noSkillsOption: "Don't seed bundled skills",
|
||||
descriptionOptional: "Description (optional)",
|
||||
modelOptional: "Model (optional)",
|
||||
modelInherit: "Inherit from clone / default",
|
||||
modelLoading: "Loading models…",
|
||||
modelNone: "No authenticated providers — set a key first",
|
||||
editModel: "Change model",
|
||||
modelSaved: "Model updated",
|
||||
modelSelect: "Select a model",
|
||||
actions: "Actions",
|
||||
},
|
||||
|
||||
pluginsPage: {
|
||||
|
||||
@ -60,6 +60,10 @@ export interface Translations {
|
||||
expand: string;
|
||||
general: string;
|
||||
messaging: string;
|
||||
// Optional: non-English locales fall back to the English literal in the
|
||||
// component until translated, matching the enriched-profiles keys.
|
||||
gateway?: string;
|
||||
gatewayHint?: string;
|
||||
pluginLoadFailed: string;
|
||||
pluginNotRegistered: string;
|
||||
};
|
||||
@ -365,6 +369,39 @@ export interface Translations {
|
||||
created: string;
|
||||
deleted: string;
|
||||
renamed: string;
|
||||
// Optional keys added for the enriched profiles experience. Non-English
|
||||
// locales fall back to the English literal in the component until
|
||||
// translated, so these are optional to avoid churning every locale file.
|
||||
activeProfile?: string;
|
||||
activeBadge?: string;
|
||||
setActive?: string;
|
||||
activeSet?: string;
|
||||
gatewayRunning?: string;
|
||||
gatewayStopped?: string;
|
||||
gatewayRunningWarning?: string;
|
||||
aliasBadge?: string;
|
||||
description?: string;
|
||||
descriptionPlaceholder?: string;
|
||||
noDescription?: string;
|
||||
editDescription?: string;
|
||||
descriptionSaved?: string;
|
||||
reviewBadge?: string;
|
||||
autoGenerate?: string;
|
||||
generating?: string;
|
||||
describeFailed?: string;
|
||||
distribution?: string;
|
||||
advancedOptions?: string;
|
||||
cloneAll?: string;
|
||||
noSkillsOption?: string;
|
||||
descriptionOptional?: string;
|
||||
modelOptional?: string;
|
||||
modelInherit?: string;
|
||||
modelLoading?: string;
|
||||
modelNone?: string;
|
||||
editModel?: string;
|
||||
modelSaved?: string;
|
||||
modelSelect?: string;
|
||||
actions?: string;
|
||||
};
|
||||
|
||||
// ── Skills page ──
|
||||
|
||||
@ -361,15 +361,58 @@ export const api = {
|
||||
deleteCronJob: (id: string, profile = "default") =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "DELETE" }),
|
||||
|
||||
// Profiles (minimal)
|
||||
// Profiles
|
||||
getProfiles: () =>
|
||||
fetchJSON<{ profiles: ProfileInfo[] }>("/api/profiles"),
|
||||
createProfile: (body: { name: string; clone_from_default: boolean }) =>
|
||||
fetchJSON<{ ok: boolean; name: string; path: string }>("/api/profiles", {
|
||||
getActiveProfile: () =>
|
||||
fetchJSON<ActiveProfileInfo>("/api/profiles/active"),
|
||||
setActiveProfile: (name: string) =>
|
||||
fetchJSON<{ ok: boolean; active: string }>("/api/profiles/active", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
createProfile: (body: {
|
||||
name: string;
|
||||
clone_from_default: boolean;
|
||||
clone_all?: boolean;
|
||||
no_skills?: boolean;
|
||||
description?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
}) =>
|
||||
fetchJSON<{ ok: boolean; name: string; path: string; model_set?: boolean }>("/api/profiles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
updateProfileDescription: (name: string, description: string) =>
|
||||
fetchJSON<{ ok: boolean; description: string; description_auto: boolean }>(
|
||||
`/api/profiles/${encodeURIComponent(name)}/description`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ description }),
|
||||
},
|
||||
),
|
||||
describeProfileAuto: (name: string, overwrite = true) =>
|
||||
fetchJSON<ProfileDescribeAutoResult>(
|
||||
`/api/profiles/${encodeURIComponent(name)}/describe-auto`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ overwrite }),
|
||||
},
|
||||
),
|
||||
setProfileModel: (name: string, provider: string, model: string) =>
|
||||
fetchJSON<{ ok: boolean; provider: string; model: string }>(
|
||||
`/api/profiles/${encodeURIComponent(name)}/model`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, model }),
|
||||
},
|
||||
),
|
||||
renameProfile: (name: string, newName: string) =>
|
||||
fetchJSON<{ ok: boolean; name: string; path: string }>(
|
||||
`/api/profiles/${encodeURIComponent(name)}`,
|
||||
@ -1170,6 +1213,8 @@ export interface EnvVarInfo {
|
||||
is_password: boolean;
|
||||
tools: string[];
|
||||
advanced: boolean;
|
||||
/** True when this var is a messaging-platform credential owned by the Channels page. */
|
||||
channel_managed?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionMessage {
|
||||
@ -1250,6 +1295,18 @@ export interface AnalyticsResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ActiveProfileInfo {
|
||||
active: string;
|
||||
current: string;
|
||||
}
|
||||
|
||||
export interface ProfileDescribeAutoResult {
|
||||
ok: boolean;
|
||||
reason: string;
|
||||
description: string | null;
|
||||
description_auto: boolean;
|
||||
}
|
||||
|
||||
export interface ProfileInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
@ -1258,6 +1315,13 @@ export interface ProfileInfo {
|
||||
provider: string | null;
|
||||
has_env: boolean;
|
||||
skill_count: number;
|
||||
gateway_running: boolean;
|
||||
description: string;
|
||||
description_auto: boolean;
|
||||
distribution_name: string | null;
|
||||
distribution_version: string | null;
|
||||
distribution_source: string | null;
|
||||
has_alias: boolean;
|
||||
}
|
||||
|
||||
export interface ModelsAnalyticsModelEntry {
|
||||
|
||||
@ -513,12 +513,12 @@ export default function EnvPage() {
|
||||
const categories = ["tool", "messaging", "setting"];
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
tool: "Tools",
|
||||
messaging: "Messaging",
|
||||
messaging: t.common.gateway ?? "Gateway",
|
||||
setting: "Settings",
|
||||
};
|
||||
for (const cat of categories) {
|
||||
const hasEntries = Object.values(vars).some(
|
||||
(info) => info.category === cat,
|
||||
(info) => info.category === cat && !info.channel_managed,
|
||||
);
|
||||
if (hasEntries) {
|
||||
items.push({ id: `section-${cat}`, label: CATEGORY_LABELS[cat] ?? cat });
|
||||
@ -526,7 +526,7 @@ export default function EnvPage() {
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [vars]);
|
||||
}, [vars, t]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!vars) {
|
||||
@ -681,21 +681,33 @@ export default function EnvPage() {
|
||||
}))
|
||||
.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
// Non-provider categories — use translated labels
|
||||
// Non-provider categories — use translated labels. Platform credentials
|
||||
// (channel_managed) are configured on the Channels page, so the messaging
|
||||
// category here is trimmed down to cross-cutting gateway / API / proxy
|
||||
// settings and relabelled accordingly.
|
||||
const CATEGORY_META_LABELS: Record<string, string> = {
|
||||
tool: t.app.nav.keys,
|
||||
messaging: t.common.messaging,
|
||||
messaging: t.common.gateway ?? "Gateway",
|
||||
setting: t.app.nav.config,
|
||||
};
|
||||
const CATEGORY_META_HINTS: Record<string, string | undefined> = {
|
||||
messaging:
|
||||
t.common.gatewayHint ??
|
||||
"Messaging platforms, the API server and webhooks are configured on the Channels page. These are gateway-wide settings (proxy/relay mode and the global allowlist).",
|
||||
};
|
||||
const otherCategories = ["tool", "messaging", "setting"];
|
||||
const nonProvider = otherCategories.map((cat) => {
|
||||
const entries = Object.entries(vars).filter(
|
||||
([, info]) => info.category === cat && (showAdvanced || !info.advanced),
|
||||
([, info]) =>
|
||||
info.category === cat &&
|
||||
!info.channel_managed &&
|
||||
(showAdvanced || !info.advanced),
|
||||
);
|
||||
const setEntries = entries.filter(([, info]) => info.is_set);
|
||||
const unsetEntries = entries.filter(([, info]) => !info.is_set);
|
||||
return {
|
||||
label: CATEGORY_META_LABELS[cat] ?? cat,
|
||||
hint: CATEGORY_META_HINTS[cat],
|
||||
icon: CATEGORY_META_ICONS[cat] ?? KeyRound,
|
||||
category: cat,
|
||||
setEntries,
|
||||
@ -839,6 +851,7 @@ function EnvCategoryCard({
|
||||
}: {
|
||||
section: {
|
||||
category: string;
|
||||
hint?: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
setEntries: [string, EnvVarInfo][];
|
||||
@ -899,6 +912,12 @@ function EnvCategoryCard({
|
||||
{section.setEntries.length} {t.common.of} {section.totalEntries}{" "}
|
||||
{t.common.configured}
|
||||
</CardDescription>
|
||||
|
||||
{section.hint && (
|
||||
<CardDescription className="text-text-tertiary">
|
||||
{section.hint}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{hasContent && (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user