rework provider/model selection with save/cancel pattern; add editable session name with cog icon

Replace the broken provider/model <select> elements (which sent empty model
on provider change, rejected by backend) with a cog-icon-driven edit flow:
- Default view shows current provider/model as text labels with a cog icon
- Clicking cog enters edit mode with <select> elements + checkmark/cancel
- Save atomically sends both provider and model; Save disabled until both set
- Cancel restores original values; whole view grays out during edit

Apply the same cog metaphor to the session Name field — inline text edit
with save/cancel, Enter to confirm, Escape to cancel. No global gray-out.
This commit is contained in:
Rob Colbert 2026-05-08 14:08:05 -04:00
parent 9abfd08529
commit 9e9bc5267a

View File

@ -39,7 +39,6 @@ export default function ChatSessionView() {
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>(WorkspaceMode.Idle); const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>(WorkspaceMode.Idle);
const [isUpdatingMode, setIsUpdatingMode] = useState(false); const [isUpdatingMode, setIsUpdatingMode] = useState(false);
const [isUpdatingProvider, setIsUpdatingProvider] = useState(false); const [isUpdatingProvider, setIsUpdatingProvider] = useState(false);
const [isUpdatingModel, setIsUpdatingModel] = useState(false);
const [isUpdatingReasoning, setIsUpdatingReasoning] = useState(false); const [isUpdatingReasoning, setIsUpdatingReasoning] = useState(false);
const [logExpanded, setLogExpanded] = useState(false); const [logExpanded, setLogExpanded] = useState(false);
const [logs, setLogs] = useState<LogEntry[]>([]); const [logs, setLogs] = useState<LogEntry[]>([]);
@ -48,6 +47,12 @@ export default function ChatSessionView() {
const [selectedProviderId, setSelectedProviderId] = useState<string>(''); const [selectedProviderId, setSelectedProviderId] = useState<string>('');
const [selectedModelId, setSelectedModelId] = useState<string>(''); const [selectedModelId, setSelectedModelId] = useState<string>('');
const [sessionReasoningEffort, setSessionReasoningEffort] = useState<string>('off'); const [sessionReasoningEffort, setSessionReasoningEffort] = useState<string>('off');
const [isEditingProvider, setIsEditingProvider] = useState(false);
const [editProviderId, setEditProviderId] = useState('');
const [editModelId, setEditModelId] = useState('');
const [isEditingName, setIsEditingName] = useState(false);
const [editName, setEditName] = useState('');
const [isUpdatingName, setIsUpdatingName] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
@ -435,29 +440,6 @@ export default function ChatSessionView() {
} }
}; };
const handleProviderChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
if (!session) return;
const newProviderId = e.target.value;
if (newProviderId === selectedProviderId) return;
setIsUpdatingProvider(true);
try {
const updatedSession = await chatSessionApi.update(session._id, {
provider: newProviderId,
selectedModel: ''
});
setSession(updatedSession);
setSelectedProviderId(newProviderId);
setSelectedModelId('');
showToast('Provider changed. Please select a model.');
} catch (err) {
showToast(`Failed to change provider: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setIsUpdatingProvider(false);
}
};
const getSelectedModelCapabilities = useCallback(() => { const getSelectedModelCapabilities = useCallback(() => {
const provider = providers.find(p => p._id === selectedProviderId); const provider = providers.find(p => p._id === selectedProviderId);
if (!provider) return null; if (!provider) return null;
@ -486,25 +468,14 @@ export default function ChatSessionView() {
} }
}; };
const handleModelChange = async (e: React.ChangeEvent<HTMLSelectElement>) => { const getProviderName = (id: string) => {
if (!session) return; return providers.find(p => p._id === id)?.name ?? 'None';
};
const newModelId = e.target.value; const getModelName = (providerId: string, modelId: string) => {
if (newModelId === selectedModelId) return; if (!providerId || !modelId) return 'None';
const provider = providers.find(p => p._id === providerId);
setIsUpdatingModel(true); return provider?.models.find(m => m.id === modelId)?.name ?? modelId;
try {
const updatedSession = await chatSessionApi.update(session._id, {
selectedModel: newModelId
});
setSession(updatedSession);
setSelectedModelId(newModelId);
showToast(`Model changed`);
} catch (err) {
showToast(`Failed to change model: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setIsUpdatingModel(false);
}
}; };
const getSortedModels = (providerId: string) => { const getSortedModels = (providerId: string) => {
@ -518,6 +489,62 @@ export default function ChatSessionView() {
return name.slice(0, maxLength - 3) + '...'; return name.slice(0, maxLength - 3) + '...';
}; };
const handleStartEditProvider = () => {
setEditProviderId(selectedProviderId);
setEditModelId(selectedModelId);
setIsEditingProvider(true);
};
const handleSaveProvider = async () => {
if (!session || !editProviderId || !editModelId) return;
setIsUpdatingProvider(true);
try {
const updatedSession = await chatSessionApi.update(session._id, {
provider: editProviderId,
selectedModel: editModelId,
});
setSession(updatedSession);
setSelectedProviderId(editProviderId);
setSelectedModelId(editModelId);
setIsEditingProvider(false);
showToast('Provider and model updated');
} catch (err) {
showToast(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setIsUpdatingProvider(false);
}
};
const handleCancelEditProvider = () => {
setEditProviderId(selectedProviderId);
setEditModelId(selectedModelId);
setIsEditingProvider(false);
};
const handleStartEditName = () => {
setEditName(session?.name || '');
setIsEditingName(true);
};
const handleSaveName = async () => {
if (!session || !editName.trim()) return;
setIsUpdatingName(true);
try {
const updatedSession = await chatSessionApi.update(session._id, { name: editName.trim() });
setSession(updatedSession);
setIsEditingName(false);
showToast('Session name updated');
} catch (err) {
showToast(`Failed to update name: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setIsUpdatingName(false);
}
};
const handleCancelEditName = () => {
setIsEditingName(false);
};
const handleSubmitPrompt = async (e: React.FormEvent) => { const handleSubmitPrompt = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!promptInput.trim() || isProcessing || !socketClient.connected || !sessionLocked) return; if (!promptInput.trim() || isProcessing || !socketClient.connected || !sessionLocked) return;
@ -595,8 +622,10 @@ export default function ChatSessionView() {
); );
}, []); }, []);
const promptDisabled = isProcessing || workspaceMode !== WorkspaceMode.Agent || !sessionLocked || !session?.selectedModel; const promptDisabled = isProcessing || workspaceMode !== WorkspaceMode.Agent || !sessionLocked || !session?.selectedModel || isEditingProvider;
const promptPlaceholder = workspaceMode !== WorkspaceMode.Agent const promptPlaceholder = isEditingProvider
? 'Save or cancel provider changes to continue.'
: workspaceMode !== WorkspaceMode.Agent
? 'Select Agent mode to enter a prompt.' ? 'Select Agent mode to enter a prompt.'
: !session?.selectedModel : !session?.selectedModel
? 'Please select a model first.' ? 'Please select a model first.'
@ -636,7 +665,10 @@ export default function ChatSessionView() {
)} )}
{/* Content Area */} {/* Content Area */}
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col relative">
{isEditingProvider && (
<div className="absolute inset-0 bg-bg-primary/80 z-30" />
)}
{/* Chat View (75%) */} {/* Chat View (75%) */}
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
{/* Messages */} {/* Messages */}
@ -700,53 +732,151 @@ export default function ChatSessionView() {
<WorkspaceModeIndicator <WorkspaceModeIndicator
mode={workspaceMode} mode={workspaceMode}
onChange={handleWorkspaceModeChange} onChange={handleWorkspaceModeChange}
disabled={!sessionLocked} disabled={!sessionLocked || isEditingProvider}
/> />
</div> </div>
<div className="p-4 space-y-3"> <div className="p-4 space-y-3">
<div> <div>
<div className="text-xs text-text-muted">Name</div> <div className="text-xs text-text-muted">Name</div>
<div className="text-text-primary">{session?.name}</div> {isEditingName ? (
<div className="flex gap-2 mt-1">
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); handleSaveName(); }
if (e.key === 'Escape') { e.preventDefault(); handleCancelEditName(); }
}}
className="flex-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none"
autoFocus
/>
<button
title="Save"
onClick={handleSaveName}
disabled={!editName.trim() || isUpdatingName}
className="text-green-500 hover:text-green-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors self-center"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
<button
title="Cancel"
onClick={handleCancelEditName}
disabled={isUpdatingName}
className="text-red-500 hover:text-red-400 disabled:opacity-30 transition-colors self-center"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
) : (
<div className="flex gap-2 items-center mt-1">
<div className="text-text-primary flex-1">{session?.name}</div>
<button
title="Edit name"
onClick={handleStartEditName}
className="text-text-muted hover:text-text-primary transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
)}
</div> </div>
<div className="flex gap-2"> {isEditingProvider ? (
<div className="flex-1"> <div className="flex gap-2">
<div className="text-xs text-text-muted">Provider</div> <div className="flex-1">
<select <div className="text-xs text-text-muted">Provider</div>
value={selectedProviderId || ''} <select
onChange={handleProviderChange} value={editProviderId || ''}
disabled={isUpdatingProvider} onChange={(e) => { setEditProviderId(e.target.value); setEditModelId(''); }}
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed" className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none"
> >
{providers.map((provider) => ( <option value="">-- Select Provider --</option>
<option key={provider._id} value={provider._id}> {providers.map((provider) => (
{provider.name} <option key={provider._id} value={provider._id}>
</option> {provider.name}
))} </option>
</select> ))}
</select>
</div>
<div className="flex-1">
<div className="text-xs text-text-muted">Model</div>
<select
value={editModelId || ''}
onChange={(e) => setEditModelId(e.target.value)}
disabled={!editProviderId}
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed truncate"
>
{!editProviderId ? (
<option value="">Select provider first</option>
) : (
<>
<option value="">-- Select Model --</option>
{getSortedModels(editProviderId).map((model) => (
<option key={model.id} value={model.id}>
{truncateModelName(model.name)}
</option>
))}
</>
)}
</select>
</div>
<div className="flex items-end gap-1 pb-1">
<button
title="Save"
onClick={handleSaveProvider}
disabled={!editProviderId || !editModelId || isUpdatingProvider}
className="text-green-500 hover:text-green-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
<button
title="Cancel"
onClick={handleCancelEditProvider}
disabled={isUpdatingProvider}
className="text-red-500 hover:text-red-400 disabled:opacity-30 transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div> </div>
<div className="flex-1"> ) : (
<div className="text-xs text-text-muted">Model</div> <div className="flex gap-2">
<select <div className="flex-1">
value={selectedModelId || ''} <div className="text-xs text-text-muted">Provider</div>
onChange={handleModelChange} <div className="text-text-primary text-sm mt-1">{getProviderName(selectedProviderId)}</div>
disabled={isUpdatingModel || !selectedProviderId} </div>
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed truncate" <div className="flex-1">
<div className="text-xs text-text-muted">Model</div>
<div
className="text-text-primary text-sm mt-1 truncate"
title={getModelName(selectedProviderId, selectedModelId)}
>
{getModelName(selectedProviderId, selectedModelId)}
</div>
</div>
<button
title="Change provider/model..."
onClick={handleStartEditProvider}
className="self-end pb-1 text-text-muted hover:text-text-primary transition-colors"
> >
{!selectedProviderId ? ( <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<option value="">Select provider first</option> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
) : ( <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<> </svg>
<option value="">-- Select Model --</option> </button>
{getSortedModels(selectedProviderId).map((model) => (
<option key={model.id} value={model.id}>
{truncateModelName(model.name)}
</option>
))}
</>
)}
</select>
</div> </div>
</div> )}
<div> <div>
<div className="text-xs text-text-muted">Mode</div> <div className="text-xs text-text-muted">Mode</div>
<select <select