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:
parent
9abfd08529
commit
9e9bc5267a
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user