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 { cn } from '@/lib/utils';
import type { Settings, SettingsResponse } from './types';
import type { CliTarget } from '@/lib/api-client';
interface FriendlyUISectionProps {
profileName: string;
target: CliTarget;
data: SettingsResponse | undefined;
currentSettings: Settings | undefined;
newEnvKey: string;
@@ -36,6 +38,7 @@ interface FriendlyUISectionProps {
export function FriendlyUISection({
profileName,
target,
data,
currentSettings,
newEnvKey,
@@ -263,7 +266,7 @@ export function FriendlyUISection({
value="info"
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>
</div>
</Tabs>
@@ -5,19 +5,30 @@
import { Button } from '@/components/ui/button';
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 { OpenRouterBadge } from '@/components/profiles/openrouter-badge';
import { isOpenRouterProfile } from './utils';
import type { Settings } from './types';
import type { CliTarget } from '@/lib/api-client';
interface HeaderSectionProps {
profileName: string;
target: CliTarget;
data: { path?: string; mtime: number } | undefined;
settings?: Settings;
isLoading: boolean;
isSaving: boolean;
isTargetSaving: boolean;
hasChanges: boolean;
isRawJsonValid: boolean;
onTargetChange: (target: CliTarget) => void;
onRefresh: () => void;
onDelete?: () => void;
onSave: () => void;
@@ -25,12 +36,15 @@ interface HeaderSectionProps {
export function HeaderSection({
profileName,
target,
data,
settings,
isLoading,
isSaving,
isTargetSaving,
hasChanges,
isRawJsonValid,
onTargetChange,
onRefresh,
onDelete,
onSave,
@@ -52,6 +66,19 @@ export function HeaderSection({
Last modified: {new Date(data.mtime).toLocaleString()}
</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 className="flex items-center gap-2">
<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 { RawEditorSection } from './raw-editor-section';
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 [conflictDialog, setConflictDialog] = useState(false);
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) => {
setConflictDialog(false);
if (overwrite) {
@@ -160,12 +200,18 @@ export function ProfileEditor({ profileName, onDelete, onHasChangesUpdate }: Pro
<div key={profileName} className="flex-1 flex flex-col overflow-hidden">
<HeaderSection
profileName={profileName}
target={resolvedTarget}
data={data}
settings={currentSettings}
isLoading={isLoading}
isSaving={saveMutation.isPending}
isTargetSaving={targetMutation.isPending}
hasChanges={computedHasChanges}
isRawJsonValid={computedIsRawJsonValid}
onTargetChange={(target) => {
if (target === resolvedTarget) return;
targetMutation.mutate(target);
}}
onRefresh={() => refetch()}
onDelete={onDelete}
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">
<FriendlyUISection
profileName={profileName}
target={resolvedTarget}
data={data}
currentSettings={currentSettings}
newEnvKey={newEnvKey}
@@ -8,13 +8,17 @@ import { Label } from '@/components/ui/label';
import { CopyButton } from '@/components/ui/copy-button';
import { Info } from 'lucide-react';
import type { SettingsResponse } from './types';
import type { CliTarget } from '@/lib/api-client';
interface InfoSectionProps {
profileName: string;
target: CliTarget;
data: SettingsResponse | undefined;
}
export function InfoSection({ profileName, data }: InfoSectionProps) {
export function InfoSection({ profileName, target, data }: InfoSectionProps) {
const isDroidTarget = target === 'droid';
return (
<ScrollArea className="h-full">
<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="text-xs">{new Date(data.mtime).toLocaleString()}</span>
</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>
@@ -62,6 +70,42 @@ export function InfoSection({ profileName, data }: InfoSectionProps) {
<CopyButton value={`ccs ${profileName} "prompt"`} size="icon" className="h-6 w-6" />
</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>
<Label className="text-xs text-muted-foreground">Set as default</Label>
<div className="mt-1 flex gap-2">
@@ -2,6 +2,8 @@
* Types for Profile Editor
*/
import type { CliTarget } from '@/lib/api-client';
export interface Settings {
env?: Record<string, string>;
}
@@ -15,6 +17,7 @@ export interface SettingsResponse {
export interface ProfileEditorProps {
profileName: string;
profileTarget?: CliTarget;
onDelete?: () => void;
onHasChangesUpdate?: (hasChanges: boolean) => void;
}