diff --git a/.gitignore b/.gitignore index 67400651..5101f145 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,7 @@ package-lock.json .claude/active-plan # Logs directory -logs/ \ No newline at end of file +logs/ + +# CI-generated files +.dev-release-info.json \ No newline at end of file diff --git a/README.md b/README.md index fecfb7ce..17ad794c 100644 --- a/README.md +++ b/README.md @@ -91,9 +91,14 @@ The dashboard provides visual management for all account types: | **Codex** | OAuth | `ccs codex` | Code generation | | **Copilot** | OAuth | `ccs copilot` | GitHub Copilot models | | **Antigravity** | OAuth | `ccs agy` | Alternative routing | +| **OpenRouter** | API Key | `ccs openrouter` | 300+ models, unified API | | **GLM** | API Key | `ccs glm` | Cost-optimized execution | | **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. + +![OpenRouter API Profiles](assets/screenshots/api-profiles-openrouter.webp) + > **OAuth providers** authenticate via browser on first run. Tokens are cached in `~/.ccs/cliproxy/auth/`. **Powered by:** diff --git a/assets/screenshots/api-profiles-openrouter.webp b/assets/screenshots/api-profiles-openrouter.webp new file mode 100644 index 00000000..fdbd035f Binary files /dev/null and b/assets/screenshots/api-profiles-openrouter.webp differ diff --git a/docs/code-standards.md b/docs/code-standards.md index 14059628..66a0bb51 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -1,6 +1,6 @@ # CCS Code Standards -Last Updated: 2025-12-19 +Last Updated: 2025-12-21 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 + +``` + +**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(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 + + )} )}