Skip to content
82 changes: 82 additions & 0 deletions apps/desktop/src/components/settings/ModelConfigPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ import {
} from "./image-generation-default";
import { copyProviderConfiguration, type ProviderCopyDraft } from "./provider-copy";
import { ImageGenerationModelRow } from "./ImageGenerationModelRow";
import {
subagentModelCandidates,
subagentModelOptInDraft,
type SubagentModelCandidate,
} from "./subagent-delegation";
import { SubagentModelRow } from "./SubagentModelRow";
import { SubagentModelsDialog } from "./SubagentModelsDialog";
import { OAuthLoginDialog } from "./OAuthLoginDialog";
import { ProviderSetupDialog } from "./ProviderSetupDialog";
import { ServiceList } from "./ServiceList";
Expand Down Expand Up @@ -106,6 +113,8 @@ export function ModelConfigPage() {
const [busyId, setBusyId] = useState<string | null>(null);
const [testingId, setTestingId] = useState<string | null>(null);
const [changingImageModel, setChangingImageModel] = useState(false);
const [changingSubagentModel, setChangingSubagentModel] = useState(false);
const [subagentEditorOpen, setSubagentEditorOpen] = useState(false);
const [refreshingCatalog, setRefreshingCatalog] = useState(false);
const [catalogStatus, setCatalogStatus] = useState<CatalogStatus | null>(null);
const [importOpen, setImportOpen] = useState(false);
Expand Down Expand Up @@ -144,6 +153,9 @@ export function ModelConfigPage() {
], null),
[settings?.imageGenerationModels, settings?.imageGeneration, providers],
);

// Every configured model the page can offer for delegation, in service order.
const subagentModels = useMemo(() => subagentModelCandidates(providers), [providers]);
const providerReady = (provider: ProviderPublic) =>
providerServesChatModels(provider, imageGenerationCandidates);

Expand Down Expand Up @@ -285,9 +297,53 @@ export function ModelConfigPage() {
variant: "error",
});
} finally {

setChangingImageModel(false);
}
};
/**
* Persist one delegation opt-in.
*
* The flag lives on the service's own model binding, so this is the same write
* the provider dialog performs. It must start from the stored service rather
* than this page's snapshot: `providers.update` replaces the whole model list,
* and a stale list would drop a model another window added in the meantime.
*/
const setSubagentDelegation = async (
candidate: SubagentModelCandidate,
selected: boolean,
) => {
setChangingSubagentModel(true);
try {
const { providers: stored } = await api.listProviders();
const provider = stored.find((entry) => entry.id === candidate.providerId);
// A plugin-owned row is refused by the RPC, and a model that disappeared
// from the stored bindings has no flag left to write.
const writable =
!!provider &&
!provider.ownerPluginId &&
provider.models.some((model) => model.id === candidate.modelId);
if (!provider || !writable) {
showToast(t("settings.subagentModelSaveFailed"), { variant: "error" });
return;
}
await api.updateProvider({
id: provider.id,
models: subagentModelOptInDraft(provider.models, candidate.modelId, selected),
});
await refreshProviders();
showToast(
t(selected ? "settings.subagentModelSelected" : "settings.subagentModelUnselected"),
{ variant: "success" },
);
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), {
variant: "error",
});
} finally {
setChangingSubagentModel(false);
}
};

const toggleEnabled = async (provider: ProviderPublic) => {
setBusyId(provider.id);
Expand Down Expand Up @@ -555,6 +611,32 @@ export function ModelConfigPage() {
</section>
) : null}

{subagentModels.length > 0 ? (
<section className="settings-card-block">
<div className="settings-panel model-default-panel">
<SubagentModelRow
candidates={subagentModels}
onEdit={() => setSubagentEditorOpen(true)}
/>
</div>
</section>
) : null}

{subagentEditorOpen ? (
<SubagentModelsDialog
candidates={subagentModels}
busy={changingSubagentModel}
onChange={(candidate, selected) =>
void setSubagentDelegation(candidate, selected)
}
onEdit={(providerId) => {
setSubagentEditorOpen(false);
setSetupFor(providerId);
}}
onClose={() => setSubagentEditorOpen(false)}
/>
) : null}

{setupFor !== null ? (
<ProviderSetupDialog
key={setupFor}
Expand Down
120 changes: 120 additions & 0 deletions apps/desktop/src/components/settings/SubagentModelRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "../ui";
import {
fitSubagentChips,
selectedSubagentModels,
type ChipFit,
type SubagentModelCandidate,
} from "./subagent-delegation";

/** Chip spacing, mirrored in `model-config.css`. */
const CHIP_GAP = 5;
/** The chip line never takes more than this share of the row. */
const CHIP_SHARE = 0.6;
/** Keep a few pixels clear so a rounding error cannot clip the last chip. */
const CHIP_SAFETY = 12;

/**
* One quiet line on the Models page: which models the AI may pick when it
* delegates, and a way into the editor.
*
* The row stays short by design — a configured install can delegate to a dozen
* models — so the chips take at most 60% of the row and the rest collapse into
* `+N`. Widths are measured from the rendered chips, because only the browser
* knows how wide a model id is at the current font and zoom.
*/
export function SubagentModelRow({
candidates,
onEdit,
}: {
candidates: SubagentModelCandidate[];
onEdit: () => void;
}) {
const { t } = useTranslation();
const selected = selectedSubagentModels(candidates);
const names = selected.map((candidate) => candidate.label);
const identity = names.join("\u0000");
const measureRef = useRef<HTMLSpanElement | null>(null);
const [fit, setFit] = useState<ChipFit>({ visible: names.length, hidden: 0 });

useLayoutEffect(() => {
if (typeof ResizeObserver === "undefined") return;
const element = measureRef.current;
if (!element) return;
const compute = () => {
const chips = Array.from(element.children) as HTMLElement[];
if (chips.length < 2) return;
const moreChip = chips[chips.length - 1];
const widths = chips.slice(0, -1).map((chip) => chip.getBoundingClientRect().width);
const rowWidth = element.parentElement?.getBoundingClientRect().width ?? 0;
setFit(
fitSubagentChips({
widths,
gap: CHIP_GAP,
available: Math.max(0, rowWidth * CHIP_SHARE - CHIP_SAFETY),
moreWidth: moreChip.getBoundingClientRect().width,
}),
);
};
compute();
const observer = new ResizeObserver(compute);
if (element.parentElement) observer.observe(element.parentElement);
return () => observer.disconnect();
}, [identity]);

// Nothing configured yet: the page hides this row rather than offering an
// editor with no models in it.
if (candidates.length === 0) return null;

return (
<div className="settings-row model-default-row model-subagent-row">
<div className="settings-row-copy model-default-copy">
<div className="settings-row-title model-default-label">
{t("settings.subagentModels")}
</div>
<div className="settings-row-detail model-default-value">
{selected.length === 0 ? (
<span className="model-default-empty" role="status">
{t("settings.subagentModelsUnset")}
</span>
) : (
<span
className="model-subagent-chips"
aria-label={names.join(", ")}
title={names.join(", ")}
>
{names.slice(0, fit.visible).map((name) => (
<span className="model-subagent-chip" key={name}>
{name}
</span>
))}
{fit.hidden > 0 ? (
<span
className="model-subagent-chip model-subagent-chip-more"
aria-label={t("settings.subagentModelsMore", { count: fit.hidden })}
>
{`+${fit.hidden}`}
</span>
) : null}
</span>
)}
{/* Off-screen twin: the only way to know how wide each chip is. */}
<span ref={measureRef} className="model-subagent-measure" aria-hidden="true">
{names.map((name) => (
<span className="model-subagent-chip" key={name}>
{name}
</span>
))}
<span className="model-subagent-chip">{`+${names.length}`}</span>
</span>
</div>
</div>
<div className="settings-row-control">
<Button size="sm" variant="secondary" onClick={onEdit}>
{t("settings.subagentModelEdit")}
</Button>
</div>
</div>
);
}
Loading