mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-18 20:14:36 +00:00
Merge pull request #172 from kaitranntt/dev
feat(ui): improve input state persistence and add save feedback
This commit is contained in:
14 files changed
+220
-17
No files matched your search
@@ -36,3 +36,6 @@ package-lock.json
|
|||||||
|
|
||||||
# Logs directory
|
# Logs directory
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
|
# CI-generated files
|
||||||
|
.dev-release-info.json
|
||||||
@@ -91,9 +91,14 @@ The dashboard provides visual management for all account types:
|
|||||||
| **Codex** | OAuth | `ccs codex` | Code generation |
|
| **Codex** | OAuth | `ccs codex` | Code generation |
|
||||||
| **Copilot** | OAuth | `ccs copilot` | GitHub Copilot models |
|
| **Copilot** | OAuth | `ccs copilot` | GitHub Copilot models |
|
||||||
| **Antigravity** | OAuth | `ccs agy` | Alternative routing |
|
| **Antigravity** | OAuth | `ccs agy` | Alternative routing |
|
||||||
|
| **OpenRouter** | API Key | `ccs openrouter` | 300+ models, unified API |
|
||||||
| **GLM** | API Key | `ccs glm` | Cost-optimized execution |
|
| **GLM** | API Key | `ccs glm` | Cost-optimized execution |
|
||||||
| **Kimi** | API Key | `ccs kimi` | Long-context, thinking mode |
|
| **Kimi** | API Key | `ccs kimi` | Long-context, thinking mode |
|
||||||
|
|
||||||
|
**OpenRouter Integration** (v7.0.0): CCS v7.0.0 adds OpenRouter with interactive model picker, dynamic discovery, and tier mapping (opus/sonnet/haiku). Create via `ccs api create --preset openrouter` or dashboard.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
> **OAuth providers** authenticate via browser on first run. Tokens are cached in `~/.ccs/cliproxy/auth/`.
|
> **OAuth providers** authenticate via browser on first run. Tokens are cached in `~/.ccs/cliproxy/auth/`.
|
||||||
|
|
||||||
**Powered by:**
|
**Powered by:**
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 213 KiB |
+87
-1
@@ -1,6 +1,6 @@
|
|||||||
# CCS Code Standards
|
# CCS Code Standards
|
||||||
|
|
||||||
Last Updated: 2025-12-19
|
Last Updated: 2025-12-21
|
||||||
|
|
||||||
Code standards, modularization patterns, and conventions for the CCS codebase.
|
Code standards, modularization patterns, and conventions for the CCS codebase.
|
||||||
|
|
||||||
@@ -377,6 +377,92 @@ export function ComponentName({ id, onSave }: Props) {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Input State Persistence Patterns
|
||||||
|
|
||||||
|
When building forms and editors that allow users to make changes, follow these patterns to prevent data loss.
|
||||||
|
|
||||||
|
### Pattern 1: Key-Based Remounting
|
||||||
|
|
||||||
|
**Use when**: Component has complex local state that should reset on prop changes.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Parent component
|
||||||
|
<ProfileEditor
|
||||||
|
key={profileId} // Forces remount when profile changes
|
||||||
|
profileId={profileId}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why**: Without `key`, React reuses the component instance. Local `useState` values persist even when props change, causing stale data bugs.
|
||||||
|
|
||||||
|
### Pattern 2: Unsaved Changes Confirmation
|
||||||
|
|
||||||
|
**Use when**: User might navigate away while editing.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Parent tracks dirty state
|
||||||
|
const [editorHasChanges, setEditorHasChanges] = useState(false);
|
||||||
|
const [pendingSwitch, setPendingSwitch] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Child notifies parent of dirty state
|
||||||
|
useEffect(() => {
|
||||||
|
onHasChangesUpdate?.(computedHasChanges);
|
||||||
|
}, [computedHasChanges, onHasChangesUpdate]);
|
||||||
|
|
||||||
|
// Intercept navigation
|
||||||
|
const handleSelect = (id: string) => {
|
||||||
|
if (editorHasChanges && currentId !== id) {
|
||||||
|
setPendingSwitch(id); // Show confirmation dialog
|
||||||
|
} else {
|
||||||
|
setCurrentId(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flow**:
|
||||||
|
1. Child computes `hasChanges` from local state vs saved data
|
||||||
|
2. Child notifies parent via callback
|
||||||
|
3. Parent intercepts navigation when dirty
|
||||||
|
4. Show confirmation dialog: "Discard & Switch" or "Cancel"
|
||||||
|
5. On confirm: reset dirty state, then switch
|
||||||
|
|
||||||
|
### Pattern 3: Auto-Save with Visual Feedback
|
||||||
|
|
||||||
|
**Use when**: Simple inputs that should save immediately.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
const handleBlur = async () => {
|
||||||
|
if (value !== savedValue) {
|
||||||
|
await saveToBackend(value);
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input value={value} onChange={...} onBlur={handleBlur} />
|
||||||
|
{saved && (
|
||||||
|
<span className="text-green-600 text-xs flex items-center gap-1">
|
||||||
|
<Check className="w-3.5 h-3.5" /> Saved
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use which**:
|
||||||
|
| Scenario | Pattern |
|
||||||
|
|----------|---------|
|
||||||
|
| Complex multi-field editor | Pattern 2 (confirmation dialog) |
|
||||||
|
| Simple single input | Pattern 3 (auto-save + feedback) |
|
||||||
|
| List item selection | Pattern 1 (key-based remount) + Pattern 2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quality Gates
|
## Quality Gates
|
||||||
|
|
||||||
### Pre-Commit Sequence
|
### Pre-Commit Sequence
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@kaitranntt/ccs",
|
"name": "@kaitranntt/ccs",
|
||||||
"version": "7.0.0",
|
"version": "7.0.0-dev.5",
|
||||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"cli",
|
"cli",
|
||||||
|
|||||||
@@ -106,6 +106,16 @@ gh release create "v${VERSION}" \
|
|||||||
|
|
||||||
log_info "Created GitHub prerelease"
|
log_info "Created GitHub prerelease"
|
||||||
|
|
||||||
|
# Save release info for Discord notification
|
||||||
|
# This file is read by send-discord-release.cjs for dev releases
|
||||||
|
cat > .dev-release-info.json << EOF
|
||||||
|
{
|
||||||
|
"version": "${VERSION}",
|
||||||
|
"notes": $(echo "$NOTES" | jq -Rs .)
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
log_info "Saved release info for Discord notification"
|
||||||
|
|
||||||
# Output for GitHub Actions
|
# Output for GitHub Actions
|
||||||
echo "version=${VERSION}" >> "${GITHUB_OUTPUT:-/dev/null}"
|
echo "version=${VERSION}" >> "${GITHUB_OUTPUT:-/dev/null}"
|
||||||
echo "released=true" >> "${GITHUB_OUTPUT:-/dev/null}"
|
echo "released=true" >> "${GITHUB_OUTPUT:-/dev/null}"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract latest release from CHANGELOG.md
|
* Extract latest release from CHANGELOG.md (for production releases)
|
||||||
*/
|
*/
|
||||||
function extractLatestRelease() {
|
function extractLatestRelease() {
|
||||||
const changelogPath = 'CHANGELOG.md';
|
const changelogPath = 'CHANGELOG.md';
|
||||||
@@ -92,6 +92,40 @@ function extractLatestRelease() {
|
|||||||
return { version, date, sections };
|
return { version, date, sections };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract dev release info from .dev-release-info.json (for dev releases)
|
||||||
|
* Falls back to CHANGELOG.md if file not found
|
||||||
|
*/
|
||||||
|
function extractDevRelease() {
|
||||||
|
const devInfoPath = '.dev-release-info.json';
|
||||||
|
|
||||||
|
if (!fs.existsSync(devInfoPath)) {
|
||||||
|
console.log('[!] .dev-release-info.json not found, falling back to CHANGELOG.md');
|
||||||
|
return extractLatestRelease();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(devInfoPath, 'utf8');
|
||||||
|
const info = JSON.parse(content);
|
||||||
|
|
||||||
|
// Parse commit messages into a simple Changes section
|
||||||
|
const changes = info.notes
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.trim().startsWith('-'))
|
||||||
|
.map((line) => line.trim().substring(1).trim())
|
||||||
|
.filter((item) => item);
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: info.version,
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
sections: changes.length > 0 ? { Changes: changes } : {},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[!] Error reading .dev-release-info.json:', error.message);
|
||||||
|
return extractLatestRelease();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create Discord embed
|
* Create Discord embed
|
||||||
*/
|
*/
|
||||||
@@ -203,7 +237,8 @@ function sendToDiscord(embed) {
|
|||||||
|
|
||||||
// Main
|
// Main
|
||||||
try {
|
try {
|
||||||
const release = extractLatestRelease();
|
const isDev = releaseType === 'dev';
|
||||||
|
const release = isDev ? extractDevRelease() : extractLatestRelease();
|
||||||
console.log(`[i] Preparing ${releaseType} notification for v${release.version}`);
|
console.log(`[i] Preparing ${releaseType} notification for v${release.version}`);
|
||||||
|
|
||||||
const embed = createEmbed(release);
|
const embed = createEmbed(release);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
|||||||
description: '349+ models from OpenAI, Anthropic, Google, Meta',
|
description: '349+ models from OpenAI, Anthropic, Google, Meta',
|
||||||
baseUrl: OPENROUTER_BASE_URL,
|
baseUrl: OPENROUTER_BASE_URL,
|
||||||
defaultProfileName: 'openrouter',
|
defaultProfileName: 'openrouter',
|
||||||
defaultModel: 'anthropic/claude-sonnet-4',
|
defaultModel: 'anthropic/claude-opus-4.5',
|
||||||
apiKeyPlaceholder: 'sk-or-...',
|
apiKeyPlaceholder: 'sk-or-...',
|
||||||
apiKeyHint: 'Get your API key at openrouter.ai/keys',
|
apiKeyHint: 'Get your API key at openrouter.ai/keys',
|
||||||
category: 'recommended',
|
category: 'recommended',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* eslint-disable react-refresh/only-export-components */
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
||||||
@@ -16,7 +16,7 @@ import { FriendlyUISection } from './friendly-ui-section';
|
|||||||
import { RawEditorSection } from './raw-editor-section';
|
import { RawEditorSection } from './raw-editor-section';
|
||||||
import type { ProfileEditorProps, Settings, SettingsResponse } from './types';
|
import type { ProfileEditorProps, Settings, SettingsResponse } from './types';
|
||||||
|
|
||||||
export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
|
export function ProfileEditor({ profileName, onDelete, onHasChangesUpdate }: ProfileEditorProps) {
|
||||||
const [localEdits, setLocalEdits] = useState<Record<string, string>>({});
|
const [localEdits, setLocalEdits] = useState<Record<string, string>>({});
|
||||||
const [conflictDialog, setConflictDialog] = useState(false);
|
const [conflictDialog, setConflictDialog] = useState(false);
|
||||||
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
|
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
|
||||||
@@ -100,6 +100,11 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
|
|||||||
return Object.keys(localEdits).length > 0;
|
return Object.keys(localEdits).length > 0;
|
||||||
}, [rawJsonEdits, localEdits, settings]);
|
}, [rawJsonEdits, localEdits, settings]);
|
||||||
|
|
||||||
|
// Notify parent of hasChanges state
|
||||||
|
useEffect(() => {
|
||||||
|
onHasChangesUpdate?.(computedHasChanges);
|
||||||
|
}, [computedHasChanges, onHasChangesUpdate]);
|
||||||
|
|
||||||
// Save mutation
|
// Save mutation
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
|||||||
@@ -16,4 +16,5 @@ export interface SettingsResponse {
|
|||||||
export interface ProfileEditorProps {
|
export interface ProfileEditorProps {
|
||||||
profileName: string;
|
profileName: string;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
|
onHasChangesUpdate?: (hasChanges: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@ export function ConfirmDialog({
|
|||||||
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
|
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
className={variant === 'destructive' ? 'bg-red-600 hover:bg-red-700' : ''}
|
className={variant === 'destructive' ? 'bg-red-600 hover:bg-red-700 text-white' : ''}
|
||||||
>
|
>
|
||||||
{confirmText}
|
{confirmText}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
|||||||
badge: '349+ models',
|
badge: '349+ models',
|
||||||
featured: true,
|
featured: true,
|
||||||
icon: '/icons/openrouter.svg',
|
icon: '/icons/openrouter.svg',
|
||||||
defaultModel: 'anthropic/claude-sonnet-4',
|
defaultModel: 'anthropic/claude-opus-4.5',
|
||||||
requiresApiKey: true,
|
requiresApiKey: true,
|
||||||
apiKeyPlaceholder: 'sk-or-...',
|
apiKeyPlaceholder: 'sk-or-...',
|
||||||
apiKeyHint: 'Get your API key at openrouter.ai/keys',
|
apiKeyHint: 'Get your API key at openrouter.ai/keys',
|
||||||
|
|||||||
+35
-4
@@ -37,6 +37,8 @@ export function ApiPage() {
|
|||||||
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
const [createMode, setCreateMode] = useState<'normal' | 'openrouter'>('normal');
|
const [createMode, setCreateMode] = useState<'normal' | 'openrouter'>('normal');
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
|
const [editorHasChanges, setEditorHasChanges] = useState(false);
|
||||||
|
const [pendingSwitch, setPendingSwitch] = useState<string | null>(null);
|
||||||
|
|
||||||
// Prefetch OpenRouter models when page loads (lazy - won't block render)
|
// Prefetch OpenRouter models when page loads (lazy - won't block render)
|
||||||
useOpenRouterModels();
|
useOpenRouterModels();
|
||||||
@@ -71,7 +73,21 @@ export function ApiPage() {
|
|||||||
// Handle create success
|
// Handle create success
|
||||||
const handleCreateSuccess = (name: string) => {
|
const handleCreateSuccess = (name: string) => {
|
||||||
setCreateDialogOpen(false);
|
setCreateDialogOpen(false);
|
||||||
setSelectedProfile(name);
|
// Use the same unsaved changes check as profile selection
|
||||||
|
if (editorHasChanges && selectedProfile !== null) {
|
||||||
|
setPendingSwitch(name);
|
||||||
|
} else {
|
||||||
|
setSelectedProfile(name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle profile selection with unsaved changes check
|
||||||
|
const handleProfileSelect = (name: string) => {
|
||||||
|
if (editorHasChanges && selectedProfile !== name) {
|
||||||
|
setPendingSwitch(name);
|
||||||
|
} else {
|
||||||
|
setSelectedProfile(name);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -168,9 +184,7 @@ export function ApiPage() {
|
|||||||
key={profile.name}
|
key={profile.name}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
isSelected={selectedProfile === profile.name}
|
isSelected={selectedProfile === profile.name}
|
||||||
onSelect={() => {
|
onSelect={() => handleProfileSelect(profile.name)}
|
||||||
setSelectedProfile(profile.name);
|
|
||||||
}}
|
|
||||||
onDelete={() => setDeleteConfirm(profile.name)}
|
onDelete={() => setDeleteConfirm(profile.name)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -206,8 +220,10 @@ export function ApiPage() {
|
|||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
{selectedProfileData ? (
|
{selectedProfileData ? (
|
||||||
<ProfileEditor
|
<ProfileEditor
|
||||||
|
key={selectedProfileData.name}
|
||||||
profileName={selectedProfileData.name}
|
profileName={selectedProfileData.name}
|
||||||
onDelete={() => setDeleteConfirm(selectedProfileData.name)}
|
onDelete={() => setDeleteConfirm(selectedProfileData.name)}
|
||||||
|
onHasChangesUpdate={setEditorHasChanges}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<OpenRouterQuickStart
|
<OpenRouterQuickStart
|
||||||
@@ -242,6 +258,21 @@ export function ApiPage() {
|
|||||||
onConfirm={() => deleteConfirm && handleDelete(deleteConfirm)}
|
onConfirm={() => deleteConfirm && handleDelete(deleteConfirm)}
|
||||||
onCancel={() => setDeleteConfirm(null)}
|
onCancel={() => setDeleteConfirm(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Unsaved Changes Confirmation */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!pendingSwitch}
|
||||||
|
title="Unsaved Changes"
|
||||||
|
description={`You have unsaved changes in "${selectedProfile}". Discard and switch to "${pendingSwitch}"?`}
|
||||||
|
confirmText="Discard & Switch"
|
||||||
|
variant="destructive"
|
||||||
|
onConfirm={() => {
|
||||||
|
setEditorHasChanges(false);
|
||||||
|
setSelectedProfile(pendingSwitch);
|
||||||
|
setPendingSwitch(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => setPendingSwitch(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ export function SettingsPage() {
|
|||||||
const [showGeminiHint, setShowGeminiHint] = useState(false);
|
const [showGeminiHint, setShowGeminiHint] = useState(false);
|
||||||
const [showOpencodeHint, setShowOpencodeHint] = useState(false);
|
const [showOpencodeHint, setShowOpencodeHint] = useState(false);
|
||||||
const [showGrokHint, setShowGrokHint] = useState(false);
|
const [showGrokHint, setShowGrokHint] = useState(false);
|
||||||
|
// Model save indicators (brief visual feedback)
|
||||||
|
const [geminiModelSaved, setGeminiModelSaved] = useState(false);
|
||||||
|
const [opencodeModelSaved, setOpencodeModelSaved] = useState(false);
|
||||||
// Global Env state
|
// Global Env state
|
||||||
const [globalEnvConfig, setGlobalEnvConfig] = useState<GlobalEnvConfig | null>(null);
|
const [globalEnvConfig, setGlobalEnvConfig] = useState<GlobalEnvConfig | null>(null);
|
||||||
const [globalEnvLoading, setGlobalEnvLoading] = useState(true);
|
const [globalEnvLoading, setGlobalEnvLoading] = useState(true);
|
||||||
@@ -332,11 +335,11 @@ export function SettingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Save Gemini model on blur (only if changed)
|
// Save Gemini model on blur (only if changed)
|
||||||
const saveGeminiModel = () => {
|
const saveGeminiModel = async () => {
|
||||||
const currentModel = config?.providers?.gemini?.model ?? 'gemini-2.5-flash';
|
const currentModel = config?.providers?.gemini?.model ?? 'gemini-2.5-flash';
|
||||||
if (geminiModelInput !== currentModel) {
|
if (geminiModelInput !== currentModel) {
|
||||||
const providers = config?.providers || {};
|
const providers = config?.providers || {};
|
||||||
saveConfig({
|
await saveConfig({
|
||||||
providers: {
|
providers: {
|
||||||
...providers,
|
...providers,
|
||||||
gemini: {
|
gemini: {
|
||||||
@@ -345,15 +348,18 @@ export function SettingsPage() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// Show saved indicator briefly
|
||||||
|
setGeminiModelSaved(true);
|
||||||
|
setTimeout(() => setGeminiModelSaved(false), 2000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save OpenCode model on blur (only if changed)
|
// Save OpenCode model on blur (only if changed)
|
||||||
const saveOpencodeModel = () => {
|
const saveOpencodeModel = async () => {
|
||||||
const currentModel = config?.providers?.opencode?.model ?? 'opencode/grok-code';
|
const currentModel = config?.providers?.opencode?.model ?? 'opencode/grok-code';
|
||||||
if (opencodeModelInput !== currentModel) {
|
if (opencodeModelInput !== currentModel) {
|
||||||
const providers = config?.providers || {};
|
const providers = config?.providers || {};
|
||||||
saveConfig({
|
await saveConfig({
|
||||||
providers: {
|
providers: {
|
||||||
...providers,
|
...providers,
|
||||||
opencode: {
|
opencode: {
|
||||||
@@ -362,6 +368,9 @@ export function SettingsPage() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// Show saved indicator briefly
|
||||||
|
setOpencodeModelSaved(true);
|
||||||
|
setTimeout(() => setOpencodeModelSaved(false), 2000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -551,6 +560,8 @@ export function SettingsPage() {
|
|||||||
isOpenCodeEnabled={isOpenCodeEnabled}
|
isOpenCodeEnabled={isOpenCodeEnabled}
|
||||||
geminiModelInput={geminiModelInput}
|
geminiModelInput={geminiModelInput}
|
||||||
opencodeModelInput={opencodeModelInput}
|
opencodeModelInput={opencodeModelInput}
|
||||||
|
geminiModelSaved={geminiModelSaved}
|
||||||
|
opencodeModelSaved={opencodeModelSaved}
|
||||||
showGeminiHint={showGeminiHint}
|
showGeminiHint={showGeminiHint}
|
||||||
showOpencodeHint={showOpencodeHint}
|
showOpencodeHint={showOpencodeHint}
|
||||||
showGrokHint={showGrokHint}
|
showGrokHint={showGrokHint}
|
||||||
@@ -703,6 +714,8 @@ interface WebSearchContentProps {
|
|||||||
isOpenCodeEnabled: boolean;
|
isOpenCodeEnabled: boolean;
|
||||||
geminiModelInput: string;
|
geminiModelInput: string;
|
||||||
opencodeModelInput: string;
|
opencodeModelInput: string;
|
||||||
|
geminiModelSaved: boolean;
|
||||||
|
opencodeModelSaved: boolean;
|
||||||
showGeminiHint: boolean;
|
showGeminiHint: boolean;
|
||||||
showOpencodeHint: boolean;
|
showOpencodeHint: boolean;
|
||||||
showGrokHint: boolean;
|
showGrokHint: boolean;
|
||||||
@@ -733,6 +746,8 @@ function WebSearchContent({
|
|||||||
isOpenCodeEnabled,
|
isOpenCodeEnabled,
|
||||||
geminiModelInput,
|
geminiModelInput,
|
||||||
opencodeModelInput,
|
opencodeModelInput,
|
||||||
|
geminiModelSaved,
|
||||||
|
opencodeModelSaved,
|
||||||
showGeminiHint,
|
showGeminiHint,
|
||||||
showOpencodeHint,
|
showOpencodeHint,
|
||||||
showGrokHint,
|
showGrokHint,
|
||||||
@@ -857,6 +872,12 @@ function WebSearchContent({
|
|||||||
className="h-8 text-sm font-mono"
|
className="h-8 text-sm font-mono"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
|
{geminiModelSaved && (
|
||||||
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 text-xs animate-in fade-in duration-200">
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
Saved
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -948,6 +969,12 @@ function WebSearchContent({
|
|||||||
className="h-8 text-sm font-mono"
|
className="h-8 text-sm font-mono"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
|
{opencodeModelSaved && (
|
||||||
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 text-xs animate-in fade-in duration-200">
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
Saved
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in new issue
Block a user