Skip to content

Commit 14c6f1d

Browse files
committed
fix(dashboard/config): handle string fallback_models + dreamer card padding
Two distinct config-page fixes that landed together because they ride the same hot path the user just reported. ## 1. "No models found" in historian / sidekick fallback dropdowns The plugin's `AgentOverrideConfigSchema` accepts `fallback_models` as either a string (single model) or `string[]` (chain): fallback_models: z.union([z.string(), z.array(z.string())]).optional() The dashboard cast every read as `string[]`, which hid two bugs that only surfaced when the user's config stored the field as a bare string: 1. The chip list iterated the value per-character, rendering single characters as "fallback" entries. 2. The "Add fallback" dropdown filter ran `String.prototype.includes(m)` against every available model. Substring-matching is aggressive — any model containing common fragments like "/", "openai", or even single common letters would be filtered out, leaving the dropdown empty with the user- reported "No models found" message and no way to add a fallback. Added a `readFallbackModels(formData, path)` helper near the other nested-access helpers that normalizes both shapes to a real `string[]`. Replaced all 8 unsafe call sites across historian, dreamer, and sidekick fallback sections (chip list `<Show>`, chip list `<For>`, chip remove `onClick`, dropdown filter, dropdown `onChange`). Dreamer wasn't reported because the user happened to have its fallback_models as an array or unset, but the same bug existed there too — fix is uniform across all three agents. ## 2. Padding between Dreamer's two `.config-card-two-col` rows The Dreamer card stacks two sibling `.config-card-two-col` blocks: Row 1: Enabled / Schedule / Inject Docs | Model / Fallbacks Row 2: User Memories | Key File Pinning Both rows had `gap: 24px` between columns within the same row, but no margin between the rows themselves, so the two visually fused into one unbroken block — Inject Docs flowed straight into User Memories with no breathing room. Added a CSS rule: .config-card-two-col + .config-card-two-col { margin-top: 24px; } Sibling-selector scoped, so it only applies when two two-col blocks stack. Other cards with a single two-col block (Memory, Historian, etc.) are unaffected. Verified: dashboard frontend build clean (147 KB bundle), biome clean.
1 parent dc4df59 commit 14c6f1d

2 files changed

Lines changed: 74 additions & 69 deletions

File tree

packages/dashboard/src/components/ConfigEditor/ConfigEditor.tsx

Lines changed: 65 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,30 @@ function setNestedValue(
216216
return clone;
217217
}
218218

219+
/**
220+
* Normalize a `fallback_models` value to a string array.
221+
*
222+
* The plugin's `AgentOverrideConfigSchema` accepts `fallback_models` as
223+
* either a string (single model) or `string[]` (chain). When stored as a
224+
* bare string, the dashboard's old `as string[]` cast caused two visible
225+
* bugs:
226+
* 1. The chip list iterated the string per-character ("o", "p", "e", ...).
227+
* 2. The "Add fallback" dropdown filter ran `String.prototype.includes(m)`
228+
* against every available model, substring-matching aggressively (any
229+
* model containing "o" or "/" would be filtered out), leaving the
230+
* dropdown empty with "No models found".
231+
*
232+
* This helper coerces both shapes to a real array so all consumers can
233+
* treat the value uniformly. Returns an empty array for `undefined`,
234+
* `null`, or other unexpected shapes.
235+
*/
236+
function readFallbackModels(formData: Record<string, unknown>, path: string): string[] {
237+
const raw = getNestedValue(formData, path);
238+
if (typeof raw === "string") return raw.length > 0 ? [raw] : [];
239+
if (Array.isArray(raw)) return raw.filter((v): v is string => typeof v === "string");
240+
return [];
241+
}
242+
219243
// ── Section icons ───────────────────────────────────────────
220244

221245
const SECTION_ICONS: Record<string, string> = {
@@ -818,12 +842,8 @@ function ConfigForm(props: {
818842
<div class="model-chain-list">
819843
<Show
820844
when={
821-
(
822-
(getNestedValue(
823-
formData(),
824-
"historian.fallback_models",
825-
) as string[]) ?? []
826-
).length > 0
845+
readFallbackModels(formData(), "historian.fallback_models")
846+
.length > 0
827847
}
828848
fallback={
829849
<span class="model-chain-empty">
@@ -832,12 +852,10 @@ function ConfigForm(props: {
832852
}
833853
>
834854
<For
835-
each={
836-
(getNestedValue(
837-
formData(),
838-
"historian.fallback_models",
839-
) as string[]) ?? []
840-
}
855+
each={readFallbackModels(
856+
formData(),
857+
"historian.fallback_models",
858+
)}
841859
>
842860
{(model, index) => (
843861
<div class="model-chain-item">
@@ -848,16 +866,14 @@ function ConfigForm(props: {
848866
type="button"
849867
class="btn sm danger"
850868
onClick={() => {
851-
const current =
852-
(getNestedValue(
853-
formData(),
854-
"historian.fallback_models",
855-
) as string[]) ?? [];
869+
const current = readFallbackModels(
870+
formData(),
871+
"historian.fallback_models",
872+
);
873+
const next = current.filter((_, i) => i !== index());
856874
handleFieldChange(
857875
"historian.fallback_models",
858-
current.filter((_, i) => i !== index()).length > 0
859-
? current.filter((_, i) => i !== index())
860-
: undefined,
876+
next.length > 0 ? next : undefined,
861877
);
862878
}}
863879
>
@@ -872,21 +888,18 @@ function ConfigForm(props: {
872888
<ModelSelect
873889
models={(models() ?? []).filter(
874890
(m) =>
875-
!(
876-
(getNestedValue(
877-
formData(),
878-
"historian.fallback_models",
879-
) as string[]) ?? []
891+
!readFallbackModels(
892+
formData(),
893+
"historian.fallback_models",
880894
).includes(m),
881895
)}
882896
value={undefined}
883897
onChange={(v) => {
884898
if (v) {
885-
const current =
886-
(getNestedValue(
887-
formData(),
888-
"historian.fallback_models",
889-
) as string[]) ?? [];
899+
const current = readFallbackModels(
900+
formData(),
901+
"historian.fallback_models",
902+
);
890903
handleFieldChange("historian.fallback_models", [...current, v]);
891904
}
892905
}}
@@ -1143,18 +1156,13 @@ function ConfigForm(props: {
11431156
<div class="model-chain-list">
11441157
<Show
11451158
when={
1146-
((getNestedValue(formData(), "dreamer.fallback_models") as string[]) ?? [])
1147-
.length > 0
1159+
readFallbackModels(formData(), "dreamer.fallback_models").length > 0
11481160
}
11491161
fallback={
11501162
<span class="model-chain-empty">Using built-in fallback chain</span>
11511163
}
11521164
>
1153-
<For
1154-
each={
1155-
(getNestedValue(formData(), "dreamer.fallback_models") as string[]) ?? []
1156-
}
1157-
>
1165+
<For each={readFallbackModels(formData(), "dreamer.fallback_models")}>
11581166
{(model, index) => (
11591167
<div class="model-chain-item">
11601168
<span class="mono" style={{ flex: 1 }}>
@@ -1164,11 +1172,10 @@ function ConfigForm(props: {
11641172
type="button"
11651173
class="btn sm danger"
11661174
onClick={() => {
1167-
const current =
1168-
(getNestedValue(
1169-
formData(),
1170-
"dreamer.fallback_models",
1171-
) as string[]) ?? [];
1175+
const current = readFallbackModels(
1176+
formData(),
1177+
"dreamer.fallback_models",
1178+
);
11721179
const updated = current.filter((_, i) => i !== index());
11731180
handleFieldChange(
11741181
"dreamer.fallback_models",
@@ -1187,17 +1194,15 @@ function ConfigForm(props: {
11871194
<ModelSelect
11881195
models={(models() ?? []).filter(
11891196
(m) =>
1190-
!(
1191-
(getNestedValue(formData(), "dreamer.fallback_models") as string[]) ??
1192-
[]
1193-
).includes(m),
1197+
!readFallbackModels(formData(), "dreamer.fallback_models").includes(m),
11941198
)}
11951199
value={undefined}
11961200
onChange={(v) => {
11971201
if (v) {
1198-
const current =
1199-
(getNestedValue(formData(), "dreamer.fallback_models") as string[]) ??
1200-
[];
1202+
const current = readFallbackModels(
1203+
formData(),
1204+
"dreamer.fallback_models",
1205+
);
12011206
handleFieldChange("dreamer.fallback_models", [...current, v]);
12021207
}
12031208
}}
@@ -1470,17 +1475,10 @@ function ConfigForm(props: {
14701475
<span class="config-field-desc">Models to try if primary fails</span>
14711476
<div class="model-chain-list">
14721477
<Show
1473-
when={
1474-
((getNestedValue(formData(), "sidekick.fallback_models") as string[]) ?? [])
1475-
.length > 0
1476-
}
1478+
when={readFallbackModels(formData(), "sidekick.fallback_models").length > 0}
14771479
fallback={<span class="model-chain-empty">Using built-in fallback chain</span>}
14781480
>
1479-
<For
1480-
each={
1481-
(getNestedValue(formData(), "sidekick.fallback_models") as string[]) ?? []
1482-
}
1483-
>
1481+
<For each={readFallbackModels(formData(), "sidekick.fallback_models")}>
14841482
{(model, index) => (
14851483
<div class="model-chain-item">
14861484
<span class="mono" style={{ flex: 1 }}>
@@ -1490,11 +1488,10 @@ function ConfigForm(props: {
14901488
type="button"
14911489
class="btn sm danger"
14921490
onClick={() => {
1493-
const current =
1494-
(getNestedValue(
1495-
formData(),
1496-
"sidekick.fallback_models",
1497-
) as string[]) ?? [];
1491+
const current = readFallbackModels(
1492+
formData(),
1493+
"sidekick.fallback_models",
1494+
);
14981495
const updated = current.filter((_, i) => i !== index());
14991496
handleFieldChange(
15001497
"sidekick.fallback_models",
@@ -1513,16 +1510,15 @@ function ConfigForm(props: {
15131510
<ModelSelect
15141511
models={(models() ?? []).filter(
15151512
(m) =>
1516-
!(
1517-
(getNestedValue(formData(), "sidekick.fallback_models") as string[]) ?? []
1518-
).includes(m),
1513+
!readFallbackModels(formData(), "sidekick.fallback_models").includes(m),
15191514
)}
15201515
value={undefined}
15211516
onChange={(v) => {
15221517
if (v) {
1523-
const current =
1524-
(getNestedValue(formData(), "sidekick.fallback_models") as string[]) ??
1525-
[];
1518+
const current = readFallbackModels(
1519+
formData(),
1520+
"sidekick.fallback_models",
1521+
);
15261522
handleFieldChange("sidekick.fallback_models", [...current, v]);
15271523
}
15281524
}}

packages/dashboard/src/styles.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1182,6 +1182,15 @@ body {
11821182
gap: 24px;
11831183
}
11841184

1185+
/* When two `.config-card-two-col` blocks are stacked as siblings inside the
1186+
same card (e.g. Dreamer's "Enabled/Schedule/Inject Docs vs Model/Fallbacks"
1187+
row above "User Memories vs Key File Pinning"), space them out vertically
1188+
to match the gap between fields in a column — otherwise the two rows
1189+
visually fuse into one unbroken block. */
1190+
.config-card-two-col + .config-card-two-col {
1191+
margin-top: 24px;
1192+
}
1193+
11851194
@media (max-width: 900px) {
11861195
.config-card-two-col {
11871196
grid-template-columns: 1fr;

0 commit comments

Comments
 (0)