Skip to content

Commit b841c9b

Browse files
feat: resolve issues #101, #102, #103
#103 - No-model warning: InputBar now shows an amber banner when the user tries to send without a model selected, instead of silently dropping the prompt. #102 - Model picker in settings: replace text+datalist inputs with a proper searchable dropdown (same style as the chat window picker, no routing profiles). Global default model picker also syncs defaultProviderId automatically. Provider default model field is now a <select> populated from fetched models. Removed redundant 'Default Provider' field (the picker sets it implicitly). #101 - Folder/sidebar UI improvements: - Indent guide lines: opacity 0.22 → 0.40 for better visibility - Folder custom icon: set emoji or image URL in Folder Settings; Simple Icons CDN picker (type a slug, preview, apply) built into the modal - Provider logo: new logoUrl field on ProviderConfig, auto-filled from Simple Icons based on provider type; shown next to conversations in the sidebar - New conversations inherit defaultModel/defaultProviderId from settings Also: SchemaFormRenderer now skips fields with renderOverride === false, allowing sections to be hidden without removing schema contributions.
1 parent 616e30b commit b841c9b

7 files changed

Lines changed: 329 additions & 43 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openconduit/core",
3-
"version": "2.0.0-beta.8",
3+
"version": "2.0.0-beta.9",
44
"description": "Shared UI components, stores, hooks, and service interface for OpenConduit",
55
"main": "src/index.ts",
66
"types": "src/index.ts",

src/components/InputBar.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export default function InputBar({ onSend, onAbort, onClear, onCompact, onTrim,
6060
const [folderFiles, setFolderFiles] = useState<FolderEntry[] | null>(null);
6161
const [folderLoading, setFolderLoading] = useState(false);
6262
const [trimConfirm, setTrimConfirm] = useState(false);
63+
const [noModelWarning, setNoModelWarning] = useState(false);
6364
const [slashMatches, setSlashMatches] = useState<SlashCommand[]>([]);
6465
const [slashIndex, setSlashIndex] = useState(0);
6566
const [slashPrefix, setSlashPrefix] = useState('');
@@ -212,12 +213,19 @@ export default function InputBar({ onSend, onAbort, onClear, onCompact, onTrim,
212213
textareaRef.current?.focus();
213214
return;
214215
}
216+
// Guard: if no model is selected, warn instead of silently dropping the prompt
217+
const effectiveModel = activeConv?.model || activeConv?.routingProfileId || settings?.defaultModel;
218+
if (!effectiveModel && conversationId) {
219+
setNoModelWarning(true);
220+
return;
221+
}
222+
setNoModelWarning(false);
215223
const fc = folderPath && folderFiles ? { rootName: folderPath.split('/').pop() ?? folderPath, rootPath: folderPath, files: folderFiles } : undefined;
216224
onSend(trimmed, attachments.length > 0 ? attachments : undefined, fc, reasoning !== 'off' ? reasoning : undefined);
217225
setContent('');
218226
setAttachments([]);
219227
textareaRef.current?.focus();
220-
}, [content, attachments, folderPath, folderFiles, onSend, reasoning, btwMode, onBtw]);
228+
}, [content, attachments, folderPath, folderFiles, onSend, reasoning, btwMode, onBtw, activeConv, settings, conversationId]);
221229

222230
const handlePickFolder = useCallback(async () => {
223231
const picked = await service.folder?.pick();
@@ -322,6 +330,7 @@ export default function InputBar({ onSend, onAbort, onClear, onCompact, onTrim,
322330
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
323331
const val = e.target.value;
324332
setContent(val);
333+
if (noModelWarning) setNoModelWarning(false);
325334
const el = e.target;
326335
el.style.height = 'auto';
327336
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
@@ -533,6 +542,23 @@ export default function InputBar({ onSend, onAbort, onClear, onCompact, onTrim,
533542
</div>
534543
)}
535544

545+
{/* No-model warning */}
546+
{noModelWarning && (
547+
<div className="flex items-center justify-between gap-2 bg-amber-950/60 border border-amber-700/50 rounded-lg px-3 py-2 text-xs text-amber-300">
548+
<div className="flex items-center gap-1.5">
549+
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
550+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
551+
</svg>
552+
<span>No model selected — pick one in the model picker above before sending.</span>
553+
</div>
554+
<button onClick={() => setNoModelWarning(false)} className="text-amber-500 hover:text-amber-300 flex-shrink-0">
555+
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
556+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
557+
</svg>
558+
</button>
559+
</div>
560+
)}
561+
536562
{/* Input box */}
537563
<div className="flex items-end gap-2 bg-slate-800 border border-slate-600 focus-within:border-blue-500 rounded-2xl px-3 py-2 transition-colors">
538564
<textarea

src/components/SettingsPanel.tsx

Lines changed: 208 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useEffect, useRef } from 'react';
1+
import React, { useState, useEffect, useRef, useCallback } from 'react';
22
import { EditorState } from '@codemirror/state';
33
import { EditorView, keymap, lineNumbers, highlightActiveLine } from '@codemirror/view';
44
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
@@ -909,6 +909,124 @@ function AiTab({
909909
);
910910
}
911911

912+
// ─── Model Dropdown Picker (for settings — no routing profiles) ───────────────
913+
914+
function ModelDropdownPicker({
915+
settings,
916+
models,
917+
loadModels,
918+
value,
919+
providerId,
920+
onChange,
921+
}: {
922+
settings: AppSettings;
923+
models: Record<string, string[]>;
924+
loadModels: (id: string) => void;
925+
value: string | undefined;
926+
providerId: string | undefined;
927+
onChange: (pid: string, model: string) => void;
928+
}) {
929+
const [open, setOpen] = useState(false);
930+
const [search, setSearch] = useState('');
931+
const ref = useRef<HTMLDivElement>(null);
932+
933+
useEffect(() => {
934+
if (open) {
935+
settings.providers.forEach((p) => { if (!models[p.id]) loadModels(p.id); });
936+
setSearch('');
937+
}
938+
}, [open, settings.providers, models, loadModels]);
939+
940+
useEffect(() => {
941+
if (!open) return;
942+
const handler = (e: MouseEvent) => {
943+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
944+
};
945+
document.addEventListener('mousedown', handler);
946+
return () => document.removeEventListener('mousedown', handler);
947+
}, [open]);
948+
949+
const providerName = settings.providers.find((p) => p.id === providerId)?.name;
950+
const label = providerName && value ? `${providerName} · ${value}` : value || 'Select model…';
951+
const lowerSearch = search.toLowerCase();
952+
953+
const select = useCallback((pid: string, m: string) => {
954+
onChange(pid, m);
955+
setOpen(false);
956+
}, [onChange]);
957+
958+
return (
959+
<div ref={ref} className="relative">
960+
<button
961+
type="button"
962+
onClick={() => setOpen((o) => !o)}
963+
className="flex items-center gap-1.5 w-full bg-slate-800 border border-slate-600 text-slate-200 text-xs rounded-lg px-2.5 py-2 outline-none hover:border-blue-500 cursor-pointer transition-colors"
964+
>
965+
<span className="truncate flex-1 text-left">{label}</span>
966+
<svg className="w-3 h-3 text-slate-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
967+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
968+
</svg>
969+
</button>
970+
971+
{open && (
972+
<div className="absolute left-0 top-full mt-1 w-full min-w-[260px] bg-slate-800 border border-slate-700 rounded-xl shadow-2xl z-50 flex flex-col overflow-hidden">
973+
<div className="p-2 border-b border-slate-700">
974+
<input
975+
autoFocus
976+
value={search}
977+
onChange={(e) => setSearch(e.target.value)}
978+
placeholder="Search models…"
979+
className="w-full bg-slate-700 text-slate-200 text-xs rounded-lg px-3 py-1.5 outline-none placeholder-slate-500"
980+
/>
981+
</div>
982+
<div className="overflow-y-auto max-h-72">
983+
{value && (
984+
<button
985+
type="button"
986+
onClick={() => { onChange('', ''); setOpen(false); }}
987+
className="w-full text-left px-3 py-2 text-xs text-slate-400 hover:bg-slate-700 transition-colors"
988+
>
989+
Clear selection
990+
</button>
991+
)}
992+
{settings.providers.map((provider) => {
993+
const all = [
994+
...(provider.customModels ?? []),
995+
...(models[provider.id] ?? []).filter((m) => !provider.customModels?.includes(m)),
996+
];
997+
const filtered = all.filter(
998+
(m) => !lowerSearch || m.toLowerCase().includes(lowerSearch) || provider.name.toLowerCase().includes(lowerSearch),
999+
);
1000+
if (filtered.length === 0) return null;
1001+
return (
1002+
<div key={provider.id}>
1003+
<div className="px-3 pt-2 pb-1 text-[10px] font-semibold text-slate-500 uppercase tracking-wide">
1004+
{provider.name}
1005+
</div>
1006+
{filtered.map((m) => {
1007+
const isActive = providerId === provider.id && value === m;
1008+
return (
1009+
<button
1010+
key={m}
1011+
type="button"
1012+
onClick={() => select(provider.id, m)}
1013+
className={`w-full text-left px-3 py-2 text-xs flex items-center gap-2 hover:bg-slate-700 transition-colors ${isActive ? 'text-blue-400' : 'text-slate-300'}`}
1014+
>
1015+
<span className={`w-3 flex-shrink-0 ${isActive ? 'opacity-100' : 'opacity-0'}`}></span>
1016+
<span className="truncate">{m}</span>
1017+
</button>
1018+
);
1019+
})}
1020+
</div>
1021+
);
1022+
})}
1023+
</div>
1024+
</div>
1025+
)}
1026+
</div>
1027+
);
1028+
}
1029+
9121030
// ─── General Tab ──────────────────────────────────────────────────────────────
9131031

9141032
function GeneralTab({
@@ -918,24 +1036,25 @@ function GeneralTab({
9181036
settings: AppSettings;
9191037
onSave: (p: Partial<AppSettings>) => Promise<void>;
9201038
}) {
1039+
const { models, loadModels } = useSettingsStore();
1040+
9211041
return (
9221042
<div className="space-y-6">
9231043
<SchemaFormRenderer
9241044
contribution={settingsRegistry.get('openconduit.general')}
9251045
settings={settings}
9261046
onSave={onSave}
9271047
renderOverrides={{
928-
defaultProviderId: (
929-
<select
930-
value={settings.defaultProviderId ?? ''}
931-
onChange={(e) => void onSave({ defaultProviderId: e.target.value || undefined })}
932-
className="select-field"
933-
>
934-
<option value="">None</option>
935-
{settings.providers.map((p) => (
936-
<option key={p.id} value={p.id}>{p.name}</option>
937-
))}
938-
</select>
1048+
defaultProviderId: false,
1049+
defaultModel: (
1050+
<ModelDropdownPicker
1051+
settings={settings}
1052+
models={models}
1053+
loadModels={loadModels}
1054+
value={settings.defaultModel}
1055+
providerId={settings.defaultProviderId}
1056+
onChange={(pid, model) => void onSave({ defaultModel: model || undefined, defaultProviderId: pid || undefined })}
1057+
/>
9391058
),
9401059
}}
9411060
/>
@@ -1483,6 +1602,19 @@ function CopilotAuthSection({
14831602
);
14841603
}
14851604

1605+
// ─── Simple Icons helpers ─────────────────────────────────────────────────────
1606+
1607+
const SIMPLE_ICONS_CDN = (slug: string) => `https://cdn.simpleicons.org/${slug}`;
1608+
1609+
const PROVIDER_ICON_SLUGS: Partial<Record<string, string>> = {
1610+
openai: 'openai',
1611+
anthropic: 'anthropic',
1612+
gemini: 'googlegemini',
1613+
ollama: 'ollama',
1614+
bedrock: 'amazonaws',
1615+
copilot: 'githubcopilot',
1616+
};
1617+
14861618
function ProviderForm({
14871619
provider,
14881620
onSave,
@@ -1493,6 +1625,22 @@ function ProviderForm({
14931625
onCancel: () => void;
14941626
}) {
14951627
const [draft, setDraft] = useState<ProviderConfig>({ ...provider });
1628+
const { models, loadModels } = useSettingsStore();
1629+
const providerModels = provider.id ? (models[provider.id] ?? []) : [];
1630+
1631+
useEffect(() => {
1632+
if (provider.id) loadModels(provider.id);
1633+
// eslint-disable-next-line react-hooks/exhaustive-deps
1634+
}, [provider.id]);
1635+
1636+
// Auto-fill logoUrl from Simple Icons when type is selected and no custom URL is set
1637+
useEffect(() => {
1638+
const slug = PROVIDER_ICON_SLUGS[draft.type];
1639+
if (slug && !draft.logoUrl) {
1640+
setDraft((d) => ({ ...d, logoUrl: SIMPLE_ICONS_CDN(slug) }));
1641+
}
1642+
// eslint-disable-next-line react-hooks/exhaustive-deps
1643+
}, [draft.type]);
14961644

14971645
const set = (key: keyof ProviderConfig, value: string) =>
14981646
setDraft((d) => ({ ...d, [key]: value }));
@@ -1526,6 +1674,42 @@ function ProviderForm({
15261674
/>
15271675
</Field>
15281676

1677+
<Field label="Logo">
1678+
<div className="space-y-2">
1679+
<div className="flex items-center gap-2">
1680+
{draft.logoUrl && (
1681+
<img src={draft.logoUrl} alt="" className="w-5 h-5 rounded-sm object-contain flex-shrink-0 invert opacity-80" />
1682+
)}
1683+
<input
1684+
type="url"
1685+
value={draft.logoUrl ?? ''}
1686+
onChange={(e) => set('logoUrl', e.target.value)}
1687+
placeholder="https://cdn.simpleicons.org/openai"
1688+
className="input-field"
1689+
/>
1690+
</div>
1691+
{PROVIDER_ICON_SLUGS[draft.type] && (
1692+
<div className="flex items-center gap-2 flex-wrap">
1693+
<span className="text-[11px] text-slate-500">Simple Icons:</span>
1694+
{Object.entries(PROVIDER_ICON_SLUGS)
1695+
.filter(([, slug]) => slug)
1696+
.slice(0, 8)
1697+
.map(([, slug]) => (
1698+
<button
1699+
key={slug}
1700+
type="button"
1701+
title={slug}
1702+
onClick={() => set('logoUrl', SIMPLE_ICONS_CDN(slug!))}
1703+
className={`p-1 rounded border transition-colors ${draft.logoUrl === SIMPLE_ICONS_CDN(slug!) ? 'border-blue-500 bg-blue-500/10' : 'border-slate-700 hover:border-slate-500'}`}
1704+
>
1705+
<img src={SIMPLE_ICONS_CDN(slug!)} alt={slug} className="w-4 h-4 object-contain invert opacity-70" />
1706+
</button>
1707+
))}
1708+
</div>
1709+
)}
1710+
</div>
1711+
</Field>
1712+
15291713
<Field label="Type">
15301714
<select
15311715
value={draft.type}
@@ -1638,23 +1822,19 @@ function ProviderForm({
16381822
<p className="text-[11px] font-semibold uppercase tracking-wider text-slate-500">Models</p>
16391823

16401824
<Field label="Default Model">
1641-
<input
1642-
type="text"
1825+
<select
16431826
value={draft.defaultModel ?? ''}
16441827
onChange={(e) => set('defaultModel', e.target.value)}
1645-
placeholder={
1646-
draft.type === 'anthropic'
1647-
? 'claude-sonnet-4-5'
1648-
: draft.type === 'lmstudio'
1649-
? 'local-model'
1650-
: draft.type === 'ollama'
1651-
? 'llama3.2'
1652-
: draft.type === 'gemini'
1653-
? 'gemini-2.0-flash'
1654-
: 'gpt-4o'
1655-
}
1656-
className="input-field"
1657-
/>
1828+
className="select-field"
1829+
>
1830+
<option value="">None</option>
1831+
{[
1832+
...(draft.customModels ?? []),
1833+
...providerModels.filter((m) => !draft.customModels?.includes(m)),
1834+
].map((m) => (
1835+
<option key={m} value={m}>{m}</option>
1836+
))}
1837+
</select>
16581838
</Field>
16591839

16601840
<ModelsField
@@ -3249,6 +3429,7 @@ function SchemaFormRenderer({
32493429
<Section key={section.title} title={section.title} description={section.description}>
32503430
{[...section.properties]
32513431
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
3432+
.filter((property) => renderOverrides[property.key] !== false)
32523433
.map((property) => (
32533434
<PropertyField
32543435
key={property.key}

0 commit comments

Comments
 (0)