(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 (
+
+
+ {saved && (
+
+ Saved
+
+ )}
+
+);
+```
+
+**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
### Pre-Commit Sequence
diff --git a/package.json b/package.json
index 1f5b859a..990e7068 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"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",
"keywords": [
"cli",
diff --git a/scripts/dev-release.sh b/scripts/dev-release.sh
index 90b5334b..1fb74bc3 100755
--- a/scripts/dev-release.sh
+++ b/scripts/dev-release.sh
@@ -106,6 +106,16 @@ gh release create "v${VERSION}" \
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
echo "version=${VERSION}" >> "${GITHUB_OUTPUT:-/dev/null}"
echo "released=true" >> "${GITHUB_OUTPUT:-/dev/null}"
diff --git a/scripts/send-discord-release.cjs b/scripts/send-discord-release.cjs
index b22da048..c1e2c970 100644
--- a/scripts/send-discord-release.cjs
+++ b/scripts/send-discord-release.cjs
@@ -34,7 +34,7 @@ try {
}
/**
- * Extract latest release from CHANGELOG.md
+ * Extract latest release from CHANGELOG.md (for production releases)
*/
function extractLatestRelease() {
const changelogPath = 'CHANGELOG.md';
@@ -92,6 +92,40 @@ function extractLatestRelease() {
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
*/
@@ -203,7 +237,8 @@ function sendToDiscord(embed) {
// Main
try {
- const release = extractLatestRelease();
+ const isDev = releaseType === 'dev';
+ const release = isDev ? extractDevRelease() : extractLatestRelease();
console.log(`[i] Preparing ${releaseType} notification for v${release.version}`);
const embed = createEmbed(release);
diff --git a/src/api/services/provider-presets.ts b/src/api/services/provider-presets.ts
index d4d1d1a0..8688cf12 100644
--- a/src/api/services/provider-presets.ts
+++ b/src/api/services/provider-presets.ts
@@ -38,7 +38,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
description: '349+ models from OpenAI, Anthropic, Google, Meta',
baseUrl: OPENROUTER_BASE_URL,
defaultProfileName: 'openrouter',
- defaultModel: 'anthropic/claude-sonnet-4',
+ defaultModel: 'anthropic/claude-opus-4.5',
apiKeyPlaceholder: 'sk-or-...',
apiKeyHint: 'Get your API key at openrouter.ai/keys',
category: 'recommended',
diff --git a/ui/src/components/profiles/editor/index.tsx b/ui/src/components/profiles/editor/index.tsx
index 01fc8b5b..da5f98c2 100644
--- a/ui/src/components/profiles/editor/index.tsx
+++ b/ui/src/components/profiles/editor/index.tsx
@@ -4,7 +4,7 @@
*/
/* 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 { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
@@ -16,7 +16,7 @@ import { FriendlyUISection } from './friendly-ui-section';
import { RawEditorSection } from './raw-editor-section';
import type { ProfileEditorProps, Settings, SettingsResponse } from './types';
-export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
+export function ProfileEditor({ profileName, onDelete, onHasChangesUpdate }: ProfileEditorProps) {
const [localEdits, setLocalEdits] = useState>({});
const [conflictDialog, setConflictDialog] = useState(false);
const [rawJsonEdits, setRawJsonEdits] = useState(null);
@@ -100,6 +100,11 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
return Object.keys(localEdits).length > 0;
}, [rawJsonEdits, localEdits, settings]);
+ // Notify parent of hasChanges state
+ useEffect(() => {
+ onHasChangesUpdate?.(computedHasChanges);
+ }, [computedHasChanges, onHasChangesUpdate]);
+
// Save mutation
const saveMutation = useMutation({
mutationFn: async () => {
diff --git a/ui/src/components/profiles/editor/types.ts b/ui/src/components/profiles/editor/types.ts
index d7e36a13..950ae645 100644
--- a/ui/src/components/profiles/editor/types.ts
+++ b/ui/src/components/profiles/editor/types.ts
@@ -16,4 +16,5 @@ export interface SettingsResponse {
export interface ProfileEditorProps {
profileName: string;
onDelete?: () => void;
+ onHasChangesUpdate?: (hasChanges: boolean) => void;
}
diff --git a/ui/src/components/shared/confirm-dialog.tsx b/ui/src/components/shared/confirm-dialog.tsx
index 43d7f940..c680a278 100644
--- a/ui/src/components/shared/confirm-dialog.tsx
+++ b/ui/src/components/shared/confirm-dialog.tsx
@@ -39,7 +39,7 @@ export function ConfirmDialog({
Cancel
{confirmText}
diff --git a/ui/src/lib/provider-presets.ts b/ui/src/lib/provider-presets.ts
index 6761640c..c91a0ffd 100644
--- a/ui/src/lib/provider-presets.ts
+++ b/ui/src/lib/provider-presets.ts
@@ -34,7 +34,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
badge: '349+ models',
featured: true,
icon: '/icons/openrouter.svg',
- defaultModel: 'anthropic/claude-sonnet-4',
+ defaultModel: 'anthropic/claude-opus-4.5',
requiresApiKey: true,
apiKeyPlaceholder: 'sk-or-...',
apiKeyHint: 'Get your API key at openrouter.ai/keys',
diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx
index 53671c79..0152d04a 100644
--- a/ui/src/pages/api.tsx
+++ b/ui/src/pages/api.tsx
@@ -37,6 +37,8 @@ export function ApiPage() {
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
const [createMode, setCreateMode] = useState<'normal' | 'openrouter'>('normal');
const [deleteConfirm, setDeleteConfirm] = useState(null);
+ const [editorHasChanges, setEditorHasChanges] = useState(false);
+ const [pendingSwitch, setPendingSwitch] = useState(null);
// Prefetch OpenRouter models when page loads (lazy - won't block render)
useOpenRouterModels();
@@ -71,7 +73,21 @@ export function ApiPage() {
// Handle create success
const handleCreateSuccess = (name: string) => {
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 (
@@ -168,9 +184,7 @@ export function ApiPage() {
key={profile.name}
profile={profile}
isSelected={selectedProfile === profile.name}
- onSelect={() => {
- setSelectedProfile(profile.name);
- }}
+ onSelect={() => handleProfileSelect(profile.name)}
onDelete={() => setDeleteConfirm(profile.name)}
/>
))}
@@ -206,8 +220,10 @@ export function ApiPage() {
{selectedProfileData ? (
setDeleteConfirm(selectedProfileData.name)}
+ onHasChangesUpdate={setEditorHasChanges}
/>
) : (
deleteConfirm && handleDelete(deleteConfirm)}
onCancel={() => setDeleteConfirm(null)}
/>
+
+ {/* Unsaved Changes Confirmation */}
+ {
+ setEditorHasChanges(false);
+ setSelectedProfile(pendingSwitch);
+ setPendingSwitch(null);
+ }}
+ onCancel={() => setPendingSwitch(null)}
+ />
);
}
diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx
index 32201e42..3d19327f 100644
--- a/ui/src/pages/settings.tsx
+++ b/ui/src/pages/settings.tsx
@@ -107,6 +107,9 @@ export function SettingsPage() {
const [showGeminiHint, setShowGeminiHint] = useState(false);
const [showOpencodeHint, setShowOpencodeHint] = 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
const [globalEnvConfig, setGlobalEnvConfig] = useState(null);
const [globalEnvLoading, setGlobalEnvLoading] = useState(true);
@@ -332,11 +335,11 @@ export function SettingsPage() {
};
// Save Gemini model on blur (only if changed)
- const saveGeminiModel = () => {
+ const saveGeminiModel = async () => {
const currentModel = config?.providers?.gemini?.model ?? 'gemini-2.5-flash';
if (geminiModelInput !== currentModel) {
const providers = config?.providers || {};
- saveConfig({
+ await saveConfig({
providers: {
...providers,
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)
- const saveOpencodeModel = () => {
+ const saveOpencodeModel = async () => {
const currentModel = config?.providers?.opencode?.model ?? 'opencode/grok-code';
if (opencodeModelInput !== currentModel) {
const providers = config?.providers || {};
- saveConfig({
+ await saveConfig({
providers: {
...providers,
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}
geminiModelInput={geminiModelInput}
opencodeModelInput={opencodeModelInput}
+ geminiModelSaved={geminiModelSaved}
+ opencodeModelSaved={opencodeModelSaved}
showGeminiHint={showGeminiHint}
showOpencodeHint={showOpencodeHint}
showGrokHint={showGrokHint}
@@ -703,6 +714,8 @@ interface WebSearchContentProps {
isOpenCodeEnabled: boolean;
geminiModelInput: string;
opencodeModelInput: string;
+ geminiModelSaved: boolean;
+ opencodeModelSaved: boolean;
showGeminiHint: boolean;
showOpencodeHint: boolean;
showGrokHint: boolean;
@@ -733,6 +746,8 @@ function WebSearchContent({
isOpenCodeEnabled,
geminiModelInput,
opencodeModelInput,
+ geminiModelSaved,
+ opencodeModelSaved,
showGeminiHint,
showOpencodeHint,
showGrokHint,
@@ -857,6 +872,12 @@ function WebSearchContent({
className="h-8 text-sm font-mono"
disabled={saving}
/>
+ {geminiModelSaved && (
+
+
+ Saved
+
+ )}
)}
@@ -948,6 +969,12 @@ function WebSearchContent({
className="h-8 text-sm font-mono"
disabled={saving}
/>
+ {opencodeModelSaved && (
+
+
+ Saved
+
+ )}
)}