Skip to content
Source
Sign inSign up
Open History to see the last commit

TextGeneratorsSettings.tsx

751 lines27.3 KB

1import {
2 type EnvironmentId,
3 isSecretReference,
4 type ProviderInstanceId,
5 type SecretReference,
6 type ServerProvider,
7 TextGeneratorConfig,
8 type TextGeneratorModel,
9} from "@t3tools/contracts";
10import { createModelSelection } from "@t3tools/shared/model";
11import { AsyncResult } from "effect/reactivity";
12import * as Option from "effect/Option";
13import * as Schema from "effect/Schema";
14import { CheckIcon, MoreVertical, PlusIcon, ServerIcon } from "lucide-react";
15import { useState } from "react";
16​
17import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings";
18import {
19 checkTextGeneratorConfig,
20 rereadTextGeneratorModels,
21 nextTextGeneratorId,
22 refreshTextGeneratorModels,
23 testTextGeneratorModel,
24 useTextGenerationTargets,
25 useTextGeneratorModels,
26 TEXT_GENERATOR_PRESETS,
27 type TextGeneratorPreset,
28 textGeneratorBrand,
29 textGeneratorDisplayName,
30} from "../../textGenerators";
31import { getTextGenerationModelOptionsByInstance } from "../../modelSelection";
32import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
33import { ProviderModelPicker } from "../chat/ProviderModelPicker";
34import {
35 AlertDialog,
36 AlertDialogClose,
37 AlertDialogDescription,
38 AlertDialogFooter,
39 AlertDialogHeader,
40 AlertDialogPopup,
41 AlertDialogTitle,
42} from "../ui/alert-dialog";
43import { Button } from "../ui/button";
44import { RefreshIcon } from "../ui/refresh-icon";
45import { toastManager } from "../ui/toast";
46import {
47 Dialog,
48 DialogDescription,
49 DialogFooter,
50 DialogHeader,
51 DialogPanel,
52 DialogPopup,
53 DialogTitle,
54} from "../ui/dialog";
55import { Input } from "../ui/input";
56import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
57import { Label } from "../ui/label";
58import { Spinner } from "../ui/spinner";
59import { Switch } from "../ui/switch";
60import { WizardFooter, WizardHeader, WizardPanel, WizardPopup, WizardSteps } from "../ui/wizard";
61import { SecretSourceInput, useSecretSources } from "./SecretSourceInput";
62import { searchableSetting } from "./settingsSearch";
63import { SETTINGS_PICKER_TRIGGER_CLASSNAME, SettingsRow, SettingsSection } from "./settingsLayout";
64​
65const decodeTextGenerator = Schema.decodeUnknownOption(TextGeneratorConfig);
66​
67type Saved = { readonly id: ProviderInstanceId; readonly config: TextGeneratorConfig };
68type Editing = Saved | "new";
69​
70/** Rows that list, add, edit, and remove one environment's text generators. */
71export function TextGeneratorsSettings({
72 environmentId,
73 environmentLabel,
74 providers,
75 readOnly,
76}: {
77 readonly environmentId: EnvironmentId;
78 readonly environmentLabel: string;
79 readonly providers: ReadonlyArray<ServerProvider>;
80 readonly readOnly: boolean;
81}) {
82 const settings = useEnvironmentSettings(environmentId);
83 const updateSettings = useUpdateEnvironmentSettings(environmentId);
84 const [editing, setEditing] = useState<Editing | null>(null);
85 const [removing, setRemoving] = useState<ProviderInstanceId | null>(null);
86 const [viewing, setViewing] = useState<Saved | null>(null);
87 const generators = Object.entries(settings.textGenerators) as Array<
88 [ProviderInstanceId, TextGeneratorConfig]
89 >;
90 const removingConfig = removing === null ? undefined : settings.textGenerators[removing];
91​
92 return (
93 <>
94 <SettingsSection
95 {...searchableSetting("text-generators")}
96 headerAction={
97 !readOnly ? (
98 <Button size="xs" variant="outline" onClick={() => setEditing("new")}>
99 <PlusIcon className="size-3" aria-hidden />
100 Add generator
101 </Button>
102 ) : null
103 }
104 >
105 {generators.length === 0 ? (
106 <SettingsRow
107 title="No text generators yet"
108 description="Add a model API to write thread titles, commit messages, and other short text without starting a coding agent."
109 />
110 ) : null}
111 {generators.map(([id, config]) => {
112 const name = textGeneratorDisplayName(config);
113 return (
114 <SettingsRow
115 key={id}
116 title={
117 <span className="inline-flex items-center gap-2.5">
118 <ProviderInstanceIcon
119 driverKind={textGeneratorBrand(config)}
120 displayName={name}
121 badgeContent="none"
122 />
123 {name}
124 </span>
125 }
126 description={
127 // Indented past the icon so it lines up with the name.
128 <span className="block truncate ps-7.5">
129 {config.enabled ? "" : "Off · "}
130 {config.baseUrl.replace(/^https?:\/\//, "")}
131 </span>
132 }
133 control={
134 readOnly ? null : (
135 <div className="flex items-center gap-2">
136 <Menu>
137 <MenuTrigger
138 render={
139 <Button
140 size="icon-sm"
141 variant="ghost-muted"
142 aria-label={`More actions for ${name}`}
143 />
144 }
145 >
146 <MoreVertical />
147 </MenuTrigger>
148 <MenuPopup align="end">
149 <MenuItem onClick={() => setEditing({ id, config })}>Edit</MenuItem>
150 <MenuItem
151 disabled={!config.enabled}
152 onClick={() => setViewing({ id, config })}
153 >
154 Show models
155 </MenuItem>
156 <MenuItem variant="destructive" onClick={() => setRemoving(id)}>
157 Remove
158 </MenuItem>
159 </MenuPopup>
160 </Menu>
161 <Switch
162 checked={config.enabled}
163 onCheckedChange={(enabled) =>
164 updateSettings({ textGenerators: { [id]: { ...config, enabled } } })
165 }
166 aria-label={`Enable ${name}`}
167 />
168 </div>
169 )
170 }
171 />
172 );
173 })}
174 </SettingsSection>
175 {editing !== null ? (
176 <TextGeneratorWizard
177 environmentId={environmentId}
178 editing={editing}
179 providers={providers}
180 onClose={() => setEditing(null)}
181 onSave={(id, config) =>
182 updateSettings(
183 { textGenerators: { [id]: config } },
184 { onSaved: () => rereadTextGeneratorModels(environmentId, id) },
185 )
186 }
187 newId={(preset) => nextTextGeneratorId(preset.id, settings, providers)}
188 />
189 ) : null}
190 {viewing !== null ? (
191 <TextGeneratorModelsDialog
192 environmentId={environmentId}
193 saved={viewing}
194 onClose={() => setViewing(null)}
195 />
196 ) : null}
197 <AlertDialog
198 open={removingConfig !== undefined}
199 onOpenChange={(open) => {
200 if (!open) setRemoving(null);
201 }}
202 >
203 <AlertDialogPopup>
204 <AlertDialogHeader>
205 <AlertDialogTitle>
206 Remove {removingConfig ? textGeneratorDisplayName(removingConfig) : ""}?
207 </AlertDialogTitle>
208 <AlertDialogDescription>
209 This deletes the API key stored on {environmentLabel}. Anything set to use this
210 generator falls back to the text generation model.
211 </AlertDialogDescription>
212 </AlertDialogHeader>
213 <AlertDialogFooter>
214 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
215 <Button
216 variant="destructive"
217 onClick={() => {
218 if (removing !== null) updateSettings({ textGenerators: { [removing]: null } });
219 setRemoving(null);
220 }}
221 >
222 Remove
223 </Button>
224 </AlertDialogFooter>
225 </AlertDialogPopup>
226 </AlertDialog>
227 </>
228 );
230​
231const STEPS = ["Connect", "Use it"] as const;
232​
233/** Text tasks a generator can write, in the order the settings pages show them. */
234const TASKS = [
235 { key: "textGenerationModelSelection", label: "Thread titles and other generated text" },
236 {
237 key: "sourceControlWriterModelSelection",
238 label: "Commit messages, pull requests, and branch names",
239 },
240 { key: "speechPostProcessingModelSelection", label: "Voice transcript cleanup" },
241] as const;
242​
243/**
244 * Connect checks the settings by listing the service's models and saves the generator; Use it then
245 * shows each text task's model picker, so any of them can switch to it. The key goes once to the
246 * environment's secret storage; settings keep only a redaction marker.
247 */
248function TextGeneratorWizard({
249 environmentId,
250 editing,
251 providers,
252 onClose,
253 onSave,
254 newId,
255}: {
256 readonly environmentId: EnvironmentId;
257 readonly editing: Editing;
258 readonly providers: ReadonlyArray<ServerProvider>;
259 readonly onClose: () => void;
260 readonly onSave: (id: ProviderInstanceId, config: TextGeneratorConfig) => void;
261 readonly newId: (preset: TextGeneratorPreset) => ProviderInstanceId;
262}) {
263 const existing = editing === "new" ? null : editing.config;
264 const [step, setStep] = useState(0);
265 const [preset, setPreset] = useState(TEXT_GENERATOR_PRESETS[0]!);
266 const [baseUrl, setBaseUrl] = useState(existing?.baseUrl ?? preset.baseUrl);
267 const [label, setLabel] = useState(existing?.label ?? "");
268 const secretSources = useSecretSources(environmentId);
269 const [keyDraft, setKeyDraft] = useState<string | SecretReference>(
270 isSecretReference(existing?.apiKey) ? existing.apiKey : "",
271 );
272 const [keyValid, setKeyValid] = useState(true);
273 const [checking, setChecking] = useState(false);
274 const [checkError, setCheckError] = useState<string | null>(null);
275 const [modelCount, setModelCount] = useState(0);
276 // Fixed once saved, so editing the form again after Back updates the same generator.
277 const [savedId, setSavedId] = useState<ProviderInstanceId | null>(
278 editing === "new" ? null : editing.id,
279 );
280​
281 // A saved literal key arrives redacted; sending the marker back keeps it.
282 const storedKey = typeof existing?.apiKey === "string" ? existing.apiKey : "";
283 const apiKey = typeof keyDraft === "string" ? keyDraft.trim() || storedKey : keyDraft;
284 const kind = existing?.kind ?? preset.kind;
285 const config = decodeTextGenerator({
286 kind,
287 ...(label.trim() ? { label: label.trim() } : {}),
288 baseUrl: baseUrl.trim(),
289 apiKey,
290 enabled: existing?.enabled ?? true,
291 });
292 const keyRequired = existing === null && preset.id !== "custom";
293 // Presets know their URL; only a custom endpoint, or one already saved as such, asks for it.
294 const showBaseUrl =
295 existing === null
296 ? preset.id === "custom"
297 : !["openai", "anthropic", "openrouter"].includes(textGeneratorBrand(existing));
298 const canConnect = keyValid && Option.isSome(config) && (!keyRequired || apiKey !== "");
299 const name = Option.isSome(config) ? textGeneratorDisplayName(config.value) : preset.label;
300​
301 const connect = async () => {
302 if (!canConnect || checking) return;
303 const id = savedId ?? newId(preset);
304 setChecking(true);
305 setCheckError(null);
306 // The form is disabled while this runs, so the checked config is the one saved below.
307 const result = await checkTextGeneratorConfig(environmentId, id, config.value);
308 setChecking(false);
309 if ("error" in result) {
310 setCheckError(result.error);
311 return;
312 }
313 onSave(id, config.value);
314 setSavedId(id);
315 setModelCount(result.models.length);
316 setStep(1);
317 };
318​
319 return (
320 <Dialog
321 open
322 onOpenChange={(open) => {
323 if (!open) onClose();
324 }}
325 >
326 <WizardPopup>
327 <WizardHeader
328 title={existing ? `Edit ${textGeneratorDisplayName(existing)}` : "Add a text generator"}
329 description="Writes thread titles, commit messages, and other short text without starting a coding agent. Your API key is stored on this environment."
330 >
331 <WizardSteps
332 steps={STEPS}
333 currentStep={step}
334 isStepDisabled={(index) => index > step}
335 onStepChange={setStep}
336 />
337 </WizardHeader>
338 <WizardPanel>
339 {step === 0 ? (
340 <form
341 className="grid gap-4"
342 onSubmit={(event) => {
343 event.preventDefault();
344 void connect();
345 }}
346 >
347 <fieldset disabled={checking} className="contents">
348 {existing === null ? (
349 <div className="grid gap-1.5">
350 <span className="text-sm font-medium">Service</span>
351 <div
352 role="radiogroup"
353 aria-label="Service"
354 className="grid grid-cols-2 gap-2 sm:grid-cols-4"
355 >
356 {TEXT_GENERATOR_PRESETS.map((candidate) => {
357 const selected = candidate.id === preset.id;
358 return (
359 <button
360 key={candidate.id}
361 type="button"
362 role="radio"
363 aria-checked={selected}
364 className={
365 selected
366 ? "flex flex-col items-center gap-2 rounded-lg border border-primary bg-primary/5 px-2 py-3 text-sm font-medium text-foreground ring-1 ring-primary ring-inset outline-none focus-visible:ring-2 focus-visible:ring-inset"
367 : "flex flex-col items-center gap-2 rounded-lg border bg-background px-2 py-3 text-sm text-muted-foreground outline-none hover:bg-accent/50 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
368 }
369 onClick={() => {
370 setPreset(candidate);
371 setBaseUrl(candidate.baseUrl);
372 setCheckError(null);
373 }}
374 >
375 {candidate.id === "custom" ? (
376 <ServerIcon className="size-5" aria-hidden />
377 ) : (
378 <ProviderInstanceIcon
379 driverKind={textGeneratorBrand(candidate)}
380 displayName={candidate.label}
381 badgeContent="none"
382 />
383 )}
384 {candidate.id === "custom" ? "Custom" : candidate.label}
385 </button>
386 );
387 })}
388 </div>
389 </div>
390 ) : null}
391 {showBaseUrl ? (
392 <div className="grid gap-1.5">
393 <Label htmlFor="text-generator-url">Base URL</Label>
394 <Input
395 id="text-generator-url"
396 placeholder="http://localhost:11434/v1"
397 value={baseUrl}
398 onChange={(event) => {
399 setBaseUrl(event.target.value);
400 setCheckError(null);
401 }}
402 spellCheck={false}
403 autoFocus={existing === null && preset.id === "custom"}
404 />
405 </div>
406 ) : null}
407 <div className="grid gap-1.5">
408 <Label htmlFor="text-generator-key">
409 {keyRequired ? "API key" : "API key (optional)"}
410 </Label>
411 <SecretSourceInput
412 id="text-generator-key"
413 aria-label="API key"
414 value={keyDraft}
415 configured={storedKey !== ""}
416 sources={secretSources}
417 onChange={(event) => {
418 setKeyDraft(event.target.value);
419 setCheckError(null);
420 }}
421 onSecretChange={(secret) => setKeyDraft(secret ?? "")}
422 autoFocus={existing === null && preset.id !== "custom"}
423 onValidityChange={setKeyValid}
424 />
425 </div>
426 <div className="grid gap-1.5">
427 <Label htmlFor="text-generator-label">Name (optional)</Label>
428 <Input
429 id="text-generator-label"
430 placeholder={
431 existing !== null
432 ? textGeneratorDisplayName(existing)
433 : preset.id === "custom"
434 ? "e.g. My local model"
435 : preset.label
436 }
437 value={label}
438 onChange={(event) => setLabel(event.target.value)}
439 />
440 </div>
441 {checkError ? (
442 <p role="alert" className="text-sm text-destructive">
443 {checkError}
444 </p>
445 ) : null}
446 </fieldset>
447 </form>
448 ) : (
449 <div className="grid gap-4">
450 <p className="text-sm text-muted-foreground">
451 {name} is connected and lists {modelCount} {modelCount === 1 ? "model" : "models"}.
452 Switch any of these to it, or keep what they use now.
453 </p>
454 {/* One grid for all rows, so pickers and Test buttons line up in columns. */}
455 <div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-x-2 gap-y-4">
456 {TASKS.map((task) => (
457 <TextGenerationTaskPicker
458 key={task.key}
459 environmentId={environmentId}
460 providers={providers}
461 settingKey={task.key}
462 label={task.label}
463 />
464 ))}
465 </div>
466 </div>
467 )}
468 </WizardPanel>
469 <WizardFooter
470 leading={
471 step > 0 ? (
472 <Button variant="ghost" onClick={() => setStep(0)}>
473 Back
474 </Button>
475 ) : null
476 }
477 >
478 {step === 0 ? (
479 <>
480 <Button variant="outline" onClick={onClose}>
481 Cancel
482 </Button>
483 <Button onClick={() => void connect()} disabled={!canConnect || checking}>
484 {checking ? <Spinner /> : null}
485 {checking ? "Connecting" : existing ? "Save and continue" : "Connect"}
486 </Button>
487 </>
488 ) : (
489 // Connect's button unmounts here; without this, focus lands on the step list.
490 <Button autoFocus onClick={onClose}>
491 Done
492 </Button>
493 )}
494 </WizardFooter>
495 </WizardPopup>
496 </Dialog>
497 );
499​
500/** One text task's model, saved as it changes, with every text generation target to pick from. */
501function TextGenerationTaskPicker({
502 environmentId,
503 providers,
504 settingKey,
505 label,
506}: {
507 readonly environmentId: EnvironmentId;
508 readonly providers: ReadonlyArray<ServerProvider>;
509 readonly settingKey: (typeof TASKS)[number]["key"];
510 readonly label: string;
511}) {
512 const settings = useEnvironmentSettings(environmentId);
513 const updateSettings = useUpdateEnvironmentSettings(environmentId);
514 const own = settings[settingKey];
515 // Source control and voice follow the text generation model until they get their own.
516 const selection = own ?? settings.textGenerationModelSelection;
517 const targets = useTextGenerationTargets({
518 environmentId,
519 settings,
520 providers: providers.filter((provider) => provider.supportsTextGeneration !== false),
521 projectId: null,
522 selections: [selection],
523 });
524 // Only a text generator's model can be tried cheaply; agents start a whole session.
525 const testable = Object.hasOwn(settings.textGenerators, selection.instanceId);
526 const testKey = `${selection.instanceId}:${selection.model}`;
527 const [testing, setTesting] = useState(false);
528 const [result, setResult] = useState<{
529 readonly key: string;
530 readonly error: string | null;
531 } | null>(null);
532 const shown = result?.key === testKey ? result : null;
533 const test = async () => {
534 setTesting(true);
535 const error = await testTextGeneratorModel(
536 environmentId,
537 selection.instanceId,
538 selection.model,
539 );
540 setTesting(false);
541 setResult({ key: testKey, error });
542 };
543 return (
544 <>
545 <div className="grid min-w-0 gap-0.5 pe-2">
546 <span className="text-sm">{label}</span>
547 {shown?.error ? (
548 <span role="alert" className="text-xs text-destructive">
549 {shown.error}
550 </span>
551 ) : shown ? (
552 <span role="status" className="text-xs text-muted-foreground">
553 {selection.model} answered. It works.
554 </span>
555 ) : own === null ? (
556 <span className="text-xs text-muted-foreground">Follows the text generation model</span>
557 ) : null}
558 </div>
559 <div className="justify-self-end">
560 <ProviderModelPicker
561 environmentId={environmentId}
562 activeInstanceId={selection.instanceId}
563 model={selection.model}
564 lockedProvider={null}
565 instanceEntries={targets.entries}
566 modelOptionsByInstance={getTextGenerationModelOptionsByInstance(
567 settings,
568 targets.providers,
569 selection.instanceId,
570 selection.model,
571 )}
572 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
573 onInstanceModelChange={(instanceId, model) =>
574 updateSettings({ [settingKey]: createModelSelection(instanceId, model) })
575 }
576 />
577 </div>
578 <div className="justify-self-end">
579 {testable ? (
580 <Button
581 size="xs"
582 variant="outline"
583 aria-busy={testing}
584 disabled={testing}
585 onClick={() => void test()}
586 >
587 {/* Keeps the label's width while testing, so the row never reflows. */}
588 <span className="relative">
589 <span className={testing ? "invisible" : undefined}>Test</span>
590 {testing ? (
591 <span className="absolute inset-0 flex items-center justify-center">
592 <Spinner />
593 </span>
594 ) : null}
595 </span>
596 </Button>
597 ) : null}
598 </div>
599 </>
600 );
602​
603/** Searchable models; selectable when `onSelect` is given. */
604function TextGeneratorModelList({
605 models,
606 selected,
607 onSelect,
608}: {
609 readonly models: ReadonlyArray<TextGeneratorModel>;
610 readonly selected?: string | null;
611 readonly onSelect?: (id: string) => void;
612}) {
613 const [query, setQuery] = useState("");
614 const needle = query.trim().toLowerCase();
615 const shown = needle
616 ? models.filter(
617 (model) =>
618 model.id.toLowerCase().includes(needle) || model.name.toLowerCase().includes(needle),
619 )
620 : models;
621 return (
622 <div className="grid gap-2">
623 {models.length > 8 ? (
624 <Input
625 type="search"
626 placeholder="Search models"
627 aria-label="Search models"
628 autoFocus
629 value={query}
630 onChange={(event) => setQuery(event.target.value)}
631 />
632 ) : null}
633 <div className="max-h-80 overflow-y-auto rounded-lg border divide-y divide-border/60">
634 {shown.length === 0 ? (
635 <p className="px-3 py-2 text-sm text-muted-foreground">No models match.</p>
636 ) : (
637 <ul role={onSelect ? "listbox" : "list"} aria-label="Models">
638 {shown.map((model) => {
639 const isSelected = model.id === selected;
640 const label = (
641 <>
642 <span className="grid min-w-0 flex-1">
643 <span className="truncate">{model.name}</span>
644 {model.name !== model.id ? (
645 <span className="truncate font-mono text-xs text-muted-foreground">
646 {model.id}
647 </span>
648 ) : null}
649 </span>
650 {isSelected ? <CheckIcon className="size-4 shrink-0 text-primary" /> : null}
651 </>
652 );
653 return (
654 <li
655 key={model.id}
656 role={onSelect ? "option" : undefined}
657 aria-selected={onSelect ? isSelected : undefined}
658 >
659 {onSelect ? (
660 <button
661 type="button"
662 className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
663 onClick={() => onSelect(model.id)}
664 >
665 {label}
666 </button>
667 ) : (
668 <div className="flex items-center gap-2 px-3 py-2 text-sm">{label}</div>
669 )}
670 </li>
671 );
672 })}
673 </ul>
674 )}
675 </div>
676 </div>
677 );
679​
680/** A saved generator's models, with a refresh that asks the API again. */
681function TextGeneratorModelsDialog({
682 environmentId,
683 saved,
684 onClose,
685}: {
686 readonly environmentId: EnvironmentId;
687 readonly saved: Saved;
688 readonly onClose: () => void;
689}) {
690 const name = textGeneratorDisplayName(saved.config);
691 const result = useTextGeneratorModels(environmentId, saved.id, saved.config);
692 const [refreshing, setRefreshing] = useState(false);
693 const refresh = async () => {
694 setRefreshing(true);
695 const outcome = await refreshTextGeneratorModels(environmentId, saved.id);
696 setRefreshing(false);
697 if ("error" in outcome) {
698 toastManager.add({
699 type: "error",
700 title: `Couldn't load ${name} models`,
701 description: outcome.error,
702 });
703 }
704 };
705 const models = AsyncResult.isSuccess(result) ? result.value : null;
706 const error = Option.getOrUndefined(AsyncResult.error(result));
707 return (
708 <Dialog
709 open
710 onOpenChange={(open) => {
711 if (!open) onClose();
712 }}
713 >
714 <DialogPopup className="max-w-md">
715 <DialogHeader>
716 <DialogTitle>{name} models</DialogTitle>
717 <DialogDescription>
718 {models === null
719 ? error
720 ? "The model list couldn't load."
721 : "Loading the model list."
722 : `${models.length} ${models.length === 1 ? "model" : "models"} available. Pick one in a text generation model picker.`}
723 </DialogDescription>
724 </DialogHeader>
725 <DialogPanel>
726 {error ? (
727 <p role="alert" className="text-sm text-destructive">
728 {error}
729 </p>
730 ) : models === null ? (
731 <Spinner tone="muted" />
732 ) : (
733 <TextGeneratorModelList models={models} />
734 )}
735 </DialogPanel>
736 <DialogFooter variant="bare">
737 <Button
738 variant="outline"
739 aria-busy={refreshing}
740 disabled={refreshing}
741 onClick={() => void refresh()}
742 >
743 <RefreshIcon refreshing={refreshing} />
744 Refresh
745 </Button>
746 <Button onClick={onClose}>Done</Button>
747 </DialogFooter>
748 </DialogPopup>
749 </Dialog>
750 );