Merge pull request #172 from kaitranntt/dev

feat(ui): improve input state persistence and add save feedback
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2025-12-21 02:10:02 -05:00
commit fe46f4dfd8
14 files changed
+221 -18

No files matched your search

+4 -1
View File
@@ -35,4 +35,7 @@ package-lock.json
.claude/active-plan .claude/active-plan
# Logs directory # Logs directory
logs/ logs/
# CI-generated files
.dev-release-info.json
+5
View File
@@ -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.
![OpenRouter API Profiles](assets/screenshots/api-profiles-openrouter.webp)
> **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
View File
@@ -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
View File
@@ -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",
+10
View File
@@ -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}"
+37 -2
View File
@@ -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);
+1 -1
View File
@@ -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',
+7 -2
View File
@@ -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;
} }
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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>
); );
} }
+31 -4
View File
@@ -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>
)} )}