feat(ui): support editing profile default target

- add target selector in profile editor header

- call profile update API to persist target changes

- update info panel usage snippets for target-aware commands
This commit is contained in:
Tam Nhu Tran
2026-02-25 15:53:22 +07:00
parent ca78e63205
commit db38ccc117
5 changed files with 127 additions and 3 deletions
@@ -20,9 +20,11 @@ import { isOpenRouterProfile, extractTierMapping, applyTierMapping } from './uti
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { Settings, SettingsResponse } from './types'; import type { Settings, SettingsResponse } from './types';
import type { CliTarget } from '@/lib/api-client';
interface FriendlyUISectionProps { interface FriendlyUISectionProps {
profileName: string; profileName: string;
target: CliTarget;
data: SettingsResponse | undefined; data: SettingsResponse | undefined;
currentSettings: Settings | undefined; currentSettings: Settings | undefined;
newEnvKey: string; newEnvKey: string;
@@ -36,6 +38,7 @@ interface FriendlyUISectionProps {
export function FriendlyUISection({ export function FriendlyUISection({
profileName, profileName,
target,
data, data,
currentSettings, currentSettings,
newEnvKey, newEnvKey,
@@ -263,7 +266,7 @@ export function FriendlyUISection({
value="info" value="info"
className="h-full mt-0 border-0 p-0 data-[state=inactive]:hidden" className="h-full mt-0 border-0 p-0 data-[state=inactive]:hidden"
> >
<InfoSection profileName={profileName} data={data} /> <InfoSection profileName={profileName} target={target} data={data} />
</TabsContent> </TabsContent>
</div> </div>
</Tabs> </Tabs>
@@ -5,19 +5,30 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Save, Loader2, Trash2, RefreshCw } from 'lucide-react'; import { Save, Loader2, Trash2, RefreshCw } from 'lucide-react';
import { OpenRouterBadge } from '@/components/profiles/openrouter-badge'; import { OpenRouterBadge } from '@/components/profiles/openrouter-badge';
import { isOpenRouterProfile } from './utils'; import { isOpenRouterProfile } from './utils';
import type { Settings } from './types'; import type { Settings } from './types';
import type { CliTarget } from '@/lib/api-client';
interface HeaderSectionProps { interface HeaderSectionProps {
profileName: string; profileName: string;
target: CliTarget;
data: { path?: string; mtime: number } | undefined; data: { path?: string; mtime: number } | undefined;
settings?: Settings; settings?: Settings;
isLoading: boolean; isLoading: boolean;
isSaving: boolean; isSaving: boolean;
isTargetSaving: boolean;
hasChanges: boolean; hasChanges: boolean;
isRawJsonValid: boolean; isRawJsonValid: boolean;
onTargetChange: (target: CliTarget) => void;
onRefresh: () => void; onRefresh: () => void;
onDelete?: () => void; onDelete?: () => void;
onSave: () => void; onSave: () => void;
@@ -25,12 +36,15 @@ interface HeaderSectionProps {
export function HeaderSection({ export function HeaderSection({
profileName, profileName,
target,
data, data,
settings, settings,
isLoading, isLoading,
isSaving, isSaving,
isTargetSaving,
hasChanges, hasChanges,
isRawJsonValid, isRawJsonValid,
onTargetChange,
onRefresh, onRefresh,
onDelete, onDelete,
onSave, onSave,
@@ -52,6 +66,19 @@ export function HeaderSection({
Last modified: {new Date(data.mtime).toLocaleString()} Last modified: {new Date(data.mtime).toLocaleString()}
</p> </p>
)} )}
<div className="mt-2 flex items-center gap-2">
<span className="text-xs text-muted-foreground">Default target:</span>
<Select value={target} onValueChange={(value) => onTargetChange(value as CliTarget)}>
<SelectTrigger className="h-7 w-[170px] text-xs" disabled={isTargetSaving}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">Claude Code</SelectItem>
<SelectItem value="droid">Factory Droid</SelectItem>
</SelectContent>
</Select>
{isTargetSaving && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
</div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={isLoading}> <Button variant="ghost" size="sm" onClick={onRefresh} disabled={isLoading}>
+48 -1
View File
@@ -15,8 +15,14 @@ import { HeaderSection } from './header-section';
import { FriendlyUISection } from './friendly-ui-section'; 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';
import type { CliTarget } from '@/lib/api-client';
export function ProfileEditor({ profileName, onDelete, onHasChangesUpdate }: ProfileEditorProps) { export function ProfileEditor({
profileName,
profileTarget,
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);
@@ -145,6 +151,40 @@ export function ProfileEditor({ profileName, onDelete, onHasChangesUpdate }: Pro
}, },
}); });
const targetMutation = useMutation({
mutationFn: async (target: CliTarget) => {
const response = await fetch(`/api/profiles/${profileName}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target }),
});
if (!response.ok) {
let errorMessage = 'Failed to update target';
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) {
errorMessage = payload.error;
}
} catch {
// Keep fallback error message.
}
throw new Error(errorMessage);
}
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.success('Default target updated');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
const resolvedTarget: CliTarget = profileTarget || 'claude';
const handleConflictResolve = async (overwrite: boolean) => { const handleConflictResolve = async (overwrite: boolean) => {
setConflictDialog(false); setConflictDialog(false);
if (overwrite) { if (overwrite) {
@@ -160,12 +200,18 @@ export function ProfileEditor({ profileName, onDelete, onHasChangesUpdate }: Pro
<div key={profileName} className="flex-1 flex flex-col overflow-hidden"> <div key={profileName} className="flex-1 flex flex-col overflow-hidden">
<HeaderSection <HeaderSection
profileName={profileName} profileName={profileName}
target={resolvedTarget}
data={data} data={data}
settings={currentSettings} settings={currentSettings}
isLoading={isLoading} isLoading={isLoading}
isSaving={saveMutation.isPending} isSaving={saveMutation.isPending}
isTargetSaving={targetMutation.isPending}
hasChanges={computedHasChanges} hasChanges={computedHasChanges}
isRawJsonValid={computedIsRawJsonValid} isRawJsonValid={computedIsRawJsonValid}
onTargetChange={(target) => {
if (target === resolvedTarget) return;
targetMutation.mutate(target);
}}
onRefresh={() => refetch()} onRefresh={() => refetch()}
onDelete={onDelete} onDelete={onDelete}
onSave={() => saveMutation.mutate()} onSave={() => saveMutation.mutate()}
@@ -191,6 +237,7 @@ export function ProfileEditor({ profileName, onDelete, onHasChangesUpdate }: Pro
<div className="flex flex-col overflow-hidden bg-muted/5 min-w-0"> <div className="flex flex-col overflow-hidden bg-muted/5 min-w-0">
<FriendlyUISection <FriendlyUISection
profileName={profileName} profileName={profileName}
target={resolvedTarget}
data={data} data={data}
currentSettings={currentSettings} currentSettings={currentSettings}
newEnvKey={newEnvKey} newEnvKey={newEnvKey}
@@ -8,13 +8,17 @@ import { Label } from '@/components/ui/label';
import { CopyButton } from '@/components/ui/copy-button'; import { CopyButton } from '@/components/ui/copy-button';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
import type { SettingsResponse } from './types'; import type { SettingsResponse } from './types';
import type { CliTarget } from '@/lib/api-client';
interface InfoSectionProps { interface InfoSectionProps {
profileName: string; profileName: string;
target: CliTarget;
data: SettingsResponse | undefined; data: SettingsResponse | undefined;
} }
export function InfoSection({ profileName, data }: InfoSectionProps) { export function InfoSection({ profileName, target, data }: InfoSectionProps) {
const isDroidTarget = target === 'droid';
return ( return (
<ScrollArea className="h-full"> <ScrollArea className="h-full">
<div className="p-4 space-y-6"> <div className="p-4 space-y-6">
@@ -44,6 +48,10 @@ export function InfoSection({ profileName, data }: InfoSectionProps) {
<span className="font-medium text-muted-foreground">Last Modified</span> <span className="font-medium text-muted-foreground">Last Modified</span>
<span className="text-xs">{new Date(data.mtime).toLocaleString()}</span> <span className="text-xs">{new Date(data.mtime).toLocaleString()}</span>
</div> </div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Default Target</span>
<span className="font-mono">{target}</span>
</div>
</> </>
)} )}
</div> </div>
@@ -62,6 +70,42 @@ export function InfoSection({ profileName, data }: InfoSectionProps) {
<CopyButton value={`ccs ${profileName} "prompt"`} size="icon" className="h-6 w-6" /> <CopyButton value={`ccs ${profileName} "prompt"`} size="icon" className="h-6 w-6" />
</div> </div>
</div> </div>
<div>
<Label className="text-xs text-muted-foreground">
{isDroidTarget ? 'Droid alias (explicit)' : 'Run on Droid'}
</Label>
<div className="mt-1 flex gap-2">
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
{isDroidTarget
? `ccsd ${profileName} "prompt"`
: `ccs ${profileName} --target droid "prompt"`}
</code>
<CopyButton
value={
isDroidTarget
? `ccsd ${profileName} "prompt"`
: `ccs ${profileName} --target droid "prompt"`
}
size="icon"
className="h-6 w-6"
/>
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">
{isDroidTarget ? 'Override to Claude' : 'Override to Claude (explicit)'}
</Label>
<div className="mt-1 flex gap-2">
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
ccs {profileName} --target claude "prompt"
</code>
<CopyButton
value={`ccs ${profileName} --target claude "prompt"`}
size="icon"
className="h-6 w-6"
/>
</div>
</div>
<div> <div>
<Label className="text-xs text-muted-foreground">Set as default</Label> <Label className="text-xs text-muted-foreground">Set as default</Label>
<div className="mt-1 flex gap-2"> <div className="mt-1 flex gap-2">
@@ -2,6 +2,8 @@
* Types for Profile Editor * Types for Profile Editor
*/ */
import type { CliTarget } from '@/lib/api-client';
export interface Settings { export interface Settings {
env?: Record<string, string>; env?: Record<string, string>;
} }
@@ -15,6 +17,7 @@ export interface SettingsResponse {
export interface ProfileEditorProps { export interface ProfileEditorProps {
profileName: string; profileName: string;
profileTarget?: CliTarget;
onDelete?: () => void; onDelete?: () => void;
onHasChangesUpdate?: (hasChanges: boolean) => void; onHasChangesUpdate?: (hasChanges: boolean) => void;
} }