Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -413,25 +413,40 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
}, [api]);

// Check if OpenAI API key is configured (for voice input)
// Subscribe to config changes so key status updates immediately when set in Settings
useEffect(() => {
let isMounted = true;
if (!api) return;
const abortController = new AbortController();
const signal = abortController.signal;

const checkOpenAIKey = async () => {
try {
const config = await api?.providers.getConfig();
if (isMounted) {
const config = await api.providers.getConfig();
if (!signal.aborted) {
setOpenAIKeySet(config?.openai?.apiKeySet ?? false);
}
} catch (error) {
console.error("Failed to check OpenAI API key:", error);
} catch {
// Ignore errors fetching config
}
};

// Initial fetch
void checkOpenAIKey();

return () => {
isMounted = false;
};
// Subscribe to provider config changes via oRPC
(async () => {
try {
const iterator = await api.providers.onConfigChanged(undefined, { signal });
for await (const _ of iterator) {
if (signal.aborted) break;
void checkOpenAIKey();
}
} catch {
// Subscription cancelled via abort signal - expected on cleanup
}
})();

return () => abortController.abort();
}, [api]);

// Allow external components (e.g., CommandPalette, Queued message edits) to insert text
Expand Down
98 changes: 36 additions & 62 deletions src/browser/components/Settings/sections/ModelsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useCallback } from "react";
import { Plus, Loader2 } from "lucide-react";
import type { ProvidersConfigMap } from "../types";
import { SUPPORTED_PROVIDERS, PROVIDER_DISPLAY_NAMES } from "@/common/constants/providers";
import { KNOWN_MODELS } from "@/common/constants/knownModels";
import { useModelLRU } from "@/browser/hooks/useModelLRU";
import { ModelRow } from "./ModelRow";
import { useAPI } from "@/browser/contexts/API";
import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";

interface NewModelForm {
provider: string;
Expand All @@ -20,22 +20,12 @@ interface EditingState {

export function ModelsSection() {
const { api } = useAPI();
const [config, setConfig] = useState<ProvidersConfigMap | null>(null);
const { config, loading, updateModelsOptimistically } = useProvidersConfig();
const [newModel, setNewModel] = useState<NewModelForm>({ provider: "", modelId: "" });
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState<EditingState | null>(null);
const [error, setError] = useState<string | null>(null);
const { defaultModel, setDefaultModel } = useModelLRU();

// Load config on mount
useEffect(() => {
if (!api) return;
void (async () => {
const cfg = await api.providers.getConfig();
setConfig(cfg ?? null);
})();
}, [api]);

// Check if a model already exists (for duplicate prevention)
const modelExists = useCallback(
(provider: string, modelId: string, excludeOriginal?: string): boolean => {
Expand All @@ -46,7 +36,7 @@ export function ModelsSection() {
[config]
);

const handleAddModel = useCallback(async () => {
const handleAddModel = useCallback(() => {
if (!config || !newModel.provider || !newModel.modelId.trim()) return;

const trimmedModelId = newModel.modelId.trim();
Expand All @@ -59,40 +49,31 @@ export function ModelsSection() {

if (!api) return;
setError(null);
setSaving(true);
try {
const currentModels = config[newModel.provider]?.models ?? [];
const updatedModels = [...currentModels, trimmedModelId];

await api.providers.setModels({ provider: newModel.provider, models: updatedModels });
// Optimistic update - returns new models array for API call
const updatedModels = updateModelsOptimistically(newModel.provider, (models) => [
...models,
trimmedModelId,
]);
setNewModel({ provider: "", modelId: "" });

// Refresh config
const cfg = await api.providers.getConfig();
setConfig(cfg ?? null);
setNewModel({ provider: "", modelId: "" });
} finally {
setSaving(false);
}
}, [api, newModel, config, modelExists]);
// Save in background
void api.providers.setModels({ provider: newModel.provider, models: updatedModels });
}, [api, newModel, config, modelExists, updateModelsOptimistically]);

const handleRemoveModel = useCallback(
async (provider: string, modelId: string) => {
(provider: string, modelId: string) => {
if (!config || !api) return;
setSaving(true);
try {
const currentModels = config[provider]?.models ?? [];
const updatedModels = currentModels.filter((m) => m !== modelId);

await api.providers.setModels({ provider, models: updatedModels });
// Optimistic update - returns new models array for API call
const updatedModels = updateModelsOptimistically(provider, (models) =>
models.filter((m) => m !== modelId)
);

// Refresh config
const cfg = await api.providers.getConfig();
setConfig(cfg ?? null);
} finally {
setSaving(false);
}
// Save in background
void api.providers.setModels({ provider, models: updatedModels });
},
[api, config]
[api, config, updateModelsOptimistically]
);

const handleStartEdit = useCallback((provider: string, modelId: string) => {
Expand All @@ -105,7 +86,7 @@ export function ModelsSection() {
setError(null);
}, []);

const handleSaveEdit = useCallback(async () => {
const handleSaveEdit = useCallback(() => {
if (!config || !editing || !api) return;

const trimmedModelId = editing.newModelId.trim();
Expand All @@ -123,26 +104,19 @@ export function ModelsSection() {
}

setError(null);
setSaving(true);
try {
const currentModels = config[editing.provider]?.models ?? [];
const updatedModels = currentModels.map((m) =>
m === editing.originalModelId ? trimmedModelId : m
);

await api.providers.setModels({ provider: editing.provider, models: updatedModels });
// Optimistic update - returns new models array for API call
const updatedModels = updateModelsOptimistically(editing.provider, (models) =>
models.map((m) => (m === editing.originalModelId ? trimmedModelId : m))
);
setEditing(null);

// Refresh config
const cfg = await api.providers.getConfig();
setConfig(cfg ?? null);
setEditing(null);
} finally {
setSaving(false);
}
}, [api, editing, config, modelExists]);
// Save in background
void api.providers.setModels({ provider: editing.provider, models: updatedModels });
}, [api, editing, config, modelExists, updateModelsOptimistically]);

// Show loading state while config is being fetched
if (config === null) {
if (loading || !config) {
return (
<div className="flex items-center justify-center gap-2 py-12">
<Loader2 className="text-muted h-5 w-5 animate-spin" />
Expand Down Expand Up @@ -211,8 +185,8 @@ export function ModelsSection() {
/>
<button
type="button"
onClick={() => void handleAddModel()}
disabled={saving || !newModel.provider || !newModel.modelId.trim()}
onClick={handleAddModel}
disabled={!newModel.provider || !newModel.modelId.trim()}
className="bg-accent hover:bg-accent-dark disabled:bg-border-medium flex items-center gap-1 rounded px-2 py-1 text-xs text-white transition-colors disabled:cursor-not-allowed"
>
<Plus className="h-3.5 w-3.5" />
Expand All @@ -237,16 +211,16 @@ export function ModelsSection() {
isEditing={isModelEditing}
editValue={isModelEditing ? editing.newModelId : undefined}
editError={isModelEditing ? error : undefined}
saving={saving}
saving={false}
hasActiveEdit={editing !== null}
onSetDefault={() => setDefaultModel(model.fullId)}
onStartEdit={() => handleStartEdit(model.provider, model.modelId)}
onSaveEdit={() => void handleSaveEdit()}
onSaveEdit={handleSaveEdit}
onCancelEdit={handleCancelEdit}
onEditChange={(value) =>
setEditing((prev) => (prev ? { ...prev, newModelId: value } : null))
}
onRemove={() => void handleRemoveModel(model.provider, model.modelId)}
onRemove={() => handleRemoveModel(model.provider, model.modelId)}
/>
);
})}
Expand Down
81 changes: 38 additions & 43 deletions src/browser/components/Settings/sections/ProvidersSection.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useCallback } from "react";
import { ChevronDown, ChevronRight, Check, X } from "lucide-react";
import type { ProvidersConfigMap } from "../types";
import { SUPPORTED_PROVIDERS } from "@/common/constants/providers";
import type { ProviderName } from "@/common/constants/providers";
import { ProviderWithIcon } from "@/browser/components/ProviderIcon";
import { useAPI } from "@/browser/contexts/API";
import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";

interface FieldConfig {
key: string;
Expand Down Expand Up @@ -66,23 +66,13 @@ function getProviderFields(provider: ProviderName): FieldConfig[] {

export function ProvidersSection() {
const { api } = useAPI();
const [config, setConfig] = useState<ProvidersConfigMap>({});
const { config, updateOptimistically } = useProvidersConfig();
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
const [editingField, setEditingField] = useState<{
provider: string;
field: string;
} | null>(null);
const [editValue, setEditValue] = useState("");
const [saving, setSaving] = useState(false);

// Load config on mount
useEffect(() => {
if (!api) return;
void (async () => {
const cfg = await api.providers.getConfig();
setConfig(cfg);
})();
}, [api]);

const handleToggleProvider = (provider: string) => {
setExpandedProvider((prev) => (prev === provider ? null : provider));
Expand All @@ -102,41 +92,48 @@ export function ProvidersSection() {
setEditValue("");
};

const handleSaveEdit = useCallback(async () => {
const handleSaveEdit = useCallback(() => {
if (!editingField || !api) return;

setSaving(true);
try {
const { provider, field } = editingField;
await api.providers.setProviderConfig({ provider, keyPath: [field], value: editValue });
const { provider, field } = editingField;

// Refresh config
const cfg = await api.providers.getConfig();
setConfig(cfg);
setEditingField(null);
setEditValue("");
} finally {
setSaving(false);
// Optimistic update for instant feedback
if (field === "apiKey") {
updateOptimistically(provider, { apiKeySet: editValue !== "" });
} else if (field === "baseUrl") {
updateOptimistically(provider, { baseUrl: editValue || undefined });
} else if (field === "voucher") {
updateOptimistically(provider, { voucherSet: editValue !== "" });
}
}, [api, editingField, editValue]);

setEditingField(null);
setEditValue("");

// Save in background
void api.providers.setProviderConfig({ provider, keyPath: [field], value: editValue });
}, [api, editingField, editValue, updateOptimistically]);

const handleClearField = useCallback(
async (provider: string, field: string) => {
(provider: string, field: string) => {
if (!api) return;
setSaving(true);
try {
await api.providers.setProviderConfig({ provider, keyPath: [field], value: "" });
const cfg = await api.providers.getConfig();
setConfig(cfg);
} finally {
setSaving(false);

// Optimistic update for instant feedback
if (field === "apiKey") {
updateOptimistically(provider, { apiKeySet: false });
} else if (field === "baseUrl") {
updateOptimistically(provider, { baseUrl: undefined });
} else if (field === "voucher") {
updateOptimistically(provider, { voucherSet: false });
}

// Save in background
void api.providers.setProviderConfig({ provider, keyPath: [field], value: "" });
},
[api]
[api, updateOptimistically]
);

const isConfigured = (provider: string): boolean => {
const providerConfig = config[provider];
const providerConfig = config?.[provider];
if (!providerConfig) return false;

// For Bedrock, check if any AWS credential field is set
Expand All @@ -155,7 +152,7 @@ export function ProvidersSection() {
};

const getFieldValue = (provider: string, field: string): string | undefined => {
const providerConfig = config[provider];
const providerConfig = config?.[provider];
if (!providerConfig) return undefined;

// For bedrock, check aws nested object for region
Expand All @@ -169,7 +166,7 @@ export function ProvidersSection() {
};

const isFieldSet = (provider: string, field: string, fieldConfig: FieldConfig): boolean => {
const providerConfig = config[provider];
const providerConfig = config?.[provider];
if (!providerConfig) return false;

if (fieldConfig.type === "secret") {
Expand Down Expand Up @@ -261,14 +258,13 @@ export function ProvidersSection() {
className="bg-modal-bg border-border-medium focus:border-accent flex-1 rounded border px-2 py-1.5 font-mono text-xs focus:outline-none"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") void handleSaveEdit();
if (e.key === "Enter") handleSaveEdit();
if (e.key === "Escape") handleCancelEdit();
}}
/>
<button
type="button"
onClick={() => void handleSaveEdit()}
disabled={saving}
onClick={handleSaveEdit}
className="p-1 text-green-500 hover:text-green-400"
>
<Check className="h-4 w-4" />
Expand Down Expand Up @@ -296,8 +292,7 @@ export function ProvidersSection() {
: fieldConfig.type === "secret" && fieldIsSet) && (
<button
type="button"
onClick={() => void handleClearField(provider, fieldConfig.key)}
disabled={saving}
onClick={() => handleClearField(provider, fieldConfig.key)}
className="text-muted hover:text-error text-xs"
>
Clear
Expand Down
Loading