fix(droid): restore tabbed layout and stabilize save state

This commit is contained in:
Tam Nhu Tran
2026-02-26 12:57:13 +07:00
parent eedb53b49e
commit a88fd68c34
2 changed files with 397 additions and 310 deletions
+40 -2
View File
@@ -97,6 +97,30 @@ interface SaveDroidRawSettingsResponse {
mtime: number;
}
function parseDroidRawSettingsText(rawText: string): {
settings: Record<string, unknown> | null;
parseError: string | null;
} {
try {
const parsed = JSON.parse(rawText);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {
settings: null,
parseError: 'JSON root must be an object.',
};
}
return {
settings: parsed as Record<string, unknown>,
parseError: null,
};
} catch (error) {
return {
settings: null,
parseError: (error as Error).message,
};
}
}
async function fetchDroidDiagnostics(): Promise<DroidDashboardDiagnostics> {
const res = await fetch(withApiBase('/droid/diagnostics'));
if (!res.ok) throw new Error('Failed to fetch Droid diagnostics');
@@ -142,9 +166,23 @@ export function useDroid() {
const saveRawSettingsMutation = useMutation({
mutationFn: saveDroidRawSettings,
onSuccess: () => {
onSuccess: (result, variables) => {
queryClient.setQueryData<DroidRawSettings>(['droid-raw-settings'], (current) => {
const path = current?.path ?? '~/.factory/settings.json';
const resolvedPath = current?.resolvedPath ?? path;
const parsed = parseDroidRawSettingsText(variables.rawText);
return {
path,
resolvedPath,
exists: true,
mtime: result.mtime,
rawText: variables.rawText,
settings: parsed.settings,
parseError: parsed.parseError,
};
});
queryClient.invalidateQueries({ queryKey: ['droid-diagnostics'] });
queryClient.invalidateQueries({ queryKey: ['droid-raw-settings'] });
},
});
+357 -308
View File
@@ -25,6 +25,7 @@ import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import {
applyAnthropicBudgetTokensToDroidByokModel,
@@ -257,7 +258,6 @@ export function DroidPage() {
expectedMtime: rawSettings?.exists ? rawSettings.mtime : undefined,
});
setRawDraftText(null);
await Promise.all([refetchDiagnostics(), refetchRawSettings()]);
toast.success('Droid settings saved');
} catch (error) {
if (isApiConflictError(error)) {
@@ -308,321 +308,370 @@ export function DroidPage() {
const providerValues = docsReference.providerValues ?? [];
const settingsHierarchy = docsReference.settingsHierarchy ?? [];
const tabContentClassName = 'mt-0 h-full border-0 p-0 data-[state=inactive]:hidden';
return (
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<TerminalSquare className="h-4 w-4" />
Runtime & Installation
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Status</span>
<Badge variant={diagnostics.binary.installed ? 'default' : 'secondary'}>
{diagnostics.binary.installed ? 'Detected' : 'Not Found'}
</Badge>
</div>
<DetailRow label="Detection source" value={diagnostics.binary.source} mono />
<DetailRow
label="Binary path"
value={diagnostics.binary.path || 'Not detected'}
mono
/>
<DetailRow
label="Install directory"
value={diagnostics.binary.installDir || 'N/A'}
mono
/>
<DetailRow label="Version" value={diagnostics.binary.version || 'Unknown'} mono />
<DetailRow
label="Override (CCS_DROID_PATH)"
value={diagnostics.binary.overridePath || 'Not set'}
mono
/>
</CardContent>
</Card>
<Tabs defaultValue="byok" className="h-full flex flex-col">
<div className="px-4 pt-4 shrink-0">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="byok">BYOK</TabsTrigger>
<TabsTrigger value="docs">Docs</TabsTrigger>
</TabsList>
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Folder className="h-4 w-4" />
Config Files
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{[diagnostics.files.settings, diagnostics.files.legacyConfig].map((file) => (
<div key={file.label} className="rounded-md border p-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-sm">{file.label}</span>
{file.exists ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-muted-foreground" />
)}
</div>
<DetailRow label="Path" value={file.path} mono />
<DetailRow label="Resolved" value={file.resolvedPath} mono />
<DetailRow label="Size" value={formatBytes(file.sizeBytes)} />
<DetailRow label="Last modified" value={formatTimestamp(file.mtimeMs)} />
{file.parseError && (
<p className="text-xs text-amber-600">Parse warning: {file.parseError}</p>
)}
{file.readError && (
<p className="text-xs text-destructive">Read warning: {file.readError}</p>
)}
</div>
))}
</CardContent>
</Card>
<div className="flex-1 min-h-0 overflow-hidden px-4 pb-4 pt-3">
<TabsContent value="overview" className={tabContentClassName}>
<ScrollArea className="h-full">
<div className="space-y-4 pr-1">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<TerminalSquare className="h-4 w-4" />
Runtime & Installation
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Status</span>
<Badge variant={diagnostics.binary.installed ? 'default' : 'secondary'}>
{diagnostics.binary.installed ? 'Detected' : 'Not Found'}
</Badge>
</div>
<DetailRow label="Detection source" value={diagnostics.binary.source} mono />
<DetailRow
label="Binary path"
value={diagnostics.binary.path || 'Not detected'}
mono
/>
<DetailRow
label="Install directory"
value={diagnostics.binary.installDir || 'N/A'}
mono
/>
<DetailRow
label="Version"
value={diagnostics.binary.version || 'Unknown'}
mono
/>
<DetailRow
label="Override (CCS_DROID_PATH)"
value={diagnostics.binary.overridePath || 'Not set'}
mono
/>
</CardContent>
</Card>
<DroidSettingsQuickControlsCard
values={quickSettingsValues}
disabled={rawSettingsLoading || !rawEditorParsed.valid}
disabledReason={
rawEditorParsed.valid ? null : `Quick settings disabled: ${rawEditorParsed.error}`
}
onEnumSettingChange={(key, value) => {
updateSettingsField(key, value);
}}
onBooleanSettingChange={(key, value) => {
updateSettingsField(key, value);
}}
onNumberSettingChange={(key, value) => {
updateSettingsField(key, value);
}}
/>
<DroidByokReasoningControlsCard
models={byokModels}
disabled={rawSettingsLoading || !rawEditorParsed.valid}
disabledReason={
rawEditorParsed.valid
? null
: `BYOK reasoning controls disabled: ${rawEditorParsed.error}`
}
onEffortChange={(modelId, effort) => {
if (!rawEditorParsed.valid) {
toast.error('Fix JSON syntax before updating BYOK reasoning settings.');
return;
}
const nextSettings = applyReasoningEffortToDroidByokModel(
rawEditorParsed.value,
modelId,
effort
);
if (!nextSettings) {
toast.error('Unable to update selected BYOK model reasoning setting.');
return;
}
updateSettingsObject(nextSettings);
}}
onAnthropicBudgetChange={(modelId, budgetTokens) => {
if (!rawEditorParsed.valid) {
toast.error('Fix JSON syntax before updating thinking budget.');
return;
}
const nextSettings = applyAnthropicBudgetTokensToDroidByokModel(
rawEditorParsed.value,
modelId,
budgetTokens
);
if (!nextSettings) {
toast.error('Thinking budget is only available for Anthropic BYOK models.');
return;
}
updateSettingsObject(nextSettings);
}}
/>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Server className="h-4 w-4" />
BYOK Summary
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<DetailRow
label="Active model selector"
value={diagnostics.byok.activeModelSelector || 'Not set'}
mono
/>
<DetailRow label="Custom models" value={String(diagnostics.byok.customModelCount)} />
<DetailRow label="CCS-managed" value={String(diagnostics.byok.ccsManagedCount)} />
<DetailRow label="User-managed" value={String(diagnostics.byok.userManagedCount)} />
<DetailRow
label="Malformed entries"
value={String(diagnostics.byok.invalidModelEntryCount)}
/>
<Separator />
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Providers</p>
<div className="flex flex-wrap gap-1.5">
{providerRows.length === 0 && (
<Badge variant="secondary" className="font-mono">
none
</Badge>
)}
{providerRows.map(([provider, count]) => (
<Badge key={provider} variant="outline" className="font-mono text-xs">
{provider}: {count}
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<ShieldCheck className="h-4 w-4" />
Docs-Aligned Notes
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{docsNotes.map((note, index) => (
<p key={`${index}-${note}`} className="text-muted-foreground">
- {renderTextWithLinks(note)}
</p>
))}
<Separator />
<div className="space-y-2">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Factory Docs
</p>
<div className="space-y-1.5">
{docsLinks.map((link) => (
<a
key={link.id}
href={link.url}
target="_blank"
rel="noreferrer"
className="block rounded-md border px-2.5 py-2 transition-colors hover:bg-muted/50"
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{link.label}</span>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground">{link.description}</p>
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/90 underline underline-offset-2">
{link.url}
</p>
</a>
))}
</div>
</div>
<Separator />
<div className="space-y-2">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Provider Fact-Check Docs
</p>
<div className="space-y-1.5">
{providerDocs.map((providerDoc) => (
<a
key={`${providerDoc.provider}-${providerDoc.url}`}
href={providerDoc.url}
target="_blank"
rel="noreferrer"
className="block rounded-md border px-2.5 py-2 transition-colors hover:bg-muted/50"
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{providerDoc.label}</span>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground">
provider: {providerDoc.provider} | format: {providerDoc.apiFormat}
</p>
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/90 underline underline-offset-2">
{providerDoc.url}
</p>
</a>
))}
</div>
</div>
<Separator />
<p className="text-xs text-muted-foreground">
Provider values: {providerValues.join(', ')}
</p>
<p className="text-xs text-muted-foreground">
Settings hierarchy: {settingsHierarchy.join(' -> ')}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Custom Models</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border overflow-hidden">
<div className="grid grid-cols-[2fr_1fr_2fr] bg-muted/40 px-3 py-2 text-xs font-medium">
<span>Name / Model</span>
<span>Provider</span>
<span>Base URL</span>
</div>
<ScrollArea className="h-52">
<div className="divide-y">
{customModels.length === 0 && (
<div className="px-3 py-4 text-xs text-muted-foreground">
No custom models
</div>
)}
{customModels.map((model) => (
<div
key={`${model.displayName}-${model.model}-${model.baseUrl}`}
className="grid grid-cols-[2fr_1fr_2fr] gap-2 px-3 py-2 text-xs"
>
<div className="min-w-0">
<p className="font-medium truncate">{model.displayName}</p>
<p className="text-muted-foreground font-mono truncate">{model.model}</p>
</div>
<div className="min-w-0">
<p className="truncate">{model.provider}</p>
<p className="text-muted-foreground">{model.apiKeyPreview || 'no-key'}</p>
</div>
<div className="min-w-0">
<p className="truncate" title={model.baseUrl}>
{model.host || model.baseUrl}
</p>
<p className="text-muted-foreground font-mono truncate">
{model.baseUrl}
</p>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Folder className="h-4 w-4" />
Config Files
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{[diagnostics.files.settings, diagnostics.files.legacyConfig].map((file) => (
<div key={file.label} className="rounded-md border p-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-sm">{file.label}</span>
{file.exists ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-muted-foreground" />
)}
</div>
<DetailRow label="Path" value={file.path} mono />
<DetailRow label="Resolved" value={file.resolvedPath} mono />
<DetailRow label="Size" value={formatBytes(file.sizeBytes)} />
<DetailRow label="Last modified" value={formatTimestamp(file.mtimeMs)} />
{file.parseError && (
<p className="text-xs text-amber-600">Parse warning: {file.parseError}</p>
)}
{file.readError && (
<p className="text-xs text-destructive">Read warning: {file.readError}</p>
)}
</div>
))}
</div>
</ScrollArea>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
{diagnostics.warnings.length > 0 && (
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600" />
Warnings
</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5">
{diagnostics.warnings.map((warning) => (
<p key={warning} className="text-sm text-amber-800 dark:text-amber-300">
- {warning}
</p>
))}
</CardContent>
</Card>
)}
{diagnostics.warnings.length > 0 && (
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600" />
Warnings
</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5">
{diagnostics.warnings.map((warning) => (
<p key={warning} className="text-sm text-amber-800 dark:text-amber-300">
- {warning}
</p>
))}
</CardContent>
</Card>
)}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="byok" className={tabContentClassName}>
<ScrollArea className="h-full">
<div className="space-y-4 pr-1">
<DroidSettingsQuickControlsCard
values={quickSettingsValues}
disabled={rawSettingsLoading || !rawEditorParsed.valid}
disabledReason={
rawEditorParsed.valid
? null
: `Quick settings disabled: ${rawEditorParsed.error}`
}
onEnumSettingChange={(key, value) => {
updateSettingsField(key, value);
}}
onBooleanSettingChange={(key, value) => {
updateSettingsField(key, value);
}}
onNumberSettingChange={(key, value) => {
updateSettingsField(key, value);
}}
/>
<DroidByokReasoningControlsCard
models={byokModels}
disabled={rawSettingsLoading || !rawEditorParsed.valid}
disabledReason={
rawEditorParsed.valid
? null
: `BYOK reasoning controls disabled: ${rawEditorParsed.error}`
}
onEffortChange={(modelId, effort) => {
if (!rawEditorParsed.valid) {
toast.error('Fix JSON syntax before updating BYOK reasoning settings.');
return;
}
const nextSettings = applyReasoningEffortToDroidByokModel(
rawEditorParsed.value,
modelId,
effort
);
if (!nextSettings) {
toast.error('Unable to update selected BYOK model reasoning setting.');
return;
}
updateSettingsObject(nextSettings);
}}
onAnthropicBudgetChange={(modelId, budgetTokens) => {
if (!rawEditorParsed.valid) {
toast.error('Fix JSON syntax before updating thinking budget.');
return;
}
const nextSettings = applyAnthropicBudgetTokensToDroidByokModel(
rawEditorParsed.value,
modelId,
budgetTokens
);
if (!nextSettings) {
toast.error('Thinking budget is only available for Anthropic BYOK models.');
return;
}
updateSettingsObject(nextSettings);
}}
/>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Server className="h-4 w-4" />
BYOK Summary
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<DetailRow
label="Active model selector"
value={diagnostics.byok.activeModelSelector || 'Not set'}
mono
/>
<DetailRow
label="Custom models"
value={String(diagnostics.byok.customModelCount)}
/>
<DetailRow
label="CCS-managed"
value={String(diagnostics.byok.ccsManagedCount)}
/>
<DetailRow
label="User-managed"
value={String(diagnostics.byok.userManagedCount)}
/>
<DetailRow
label="Malformed entries"
value={String(diagnostics.byok.invalidModelEntryCount)}
/>
<Separator />
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Providers</p>
<div className="flex flex-wrap gap-1.5">
{providerRows.length === 0 && (
<Badge variant="secondary" className="font-mono">
none
</Badge>
)}
{providerRows.map(([provider, count]) => (
<Badge key={provider} variant="outline" className="font-mono text-xs">
{provider}: {count}
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Custom Models</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border overflow-hidden">
<div className="grid grid-cols-[2fr_1fr_2fr] bg-muted/40 px-3 py-2 text-xs font-medium">
<span>Name / Model</span>
<span>Provider</span>
<span>Base URL</span>
</div>
<ScrollArea className="h-52">
<div className="divide-y">
{customModels.length === 0 && (
<div className="px-3 py-4 text-xs text-muted-foreground">
No custom models
</div>
)}
{customModels.map((model) => (
<div
key={`${model.displayName}-${model.model}-${model.baseUrl}`}
className="grid grid-cols-[2fr_1fr_2fr] gap-2 px-3 py-2 text-xs"
>
<div className="min-w-0">
<p className="font-medium truncate">{model.displayName}</p>
<p className="text-muted-foreground font-mono truncate">
{model.model}
</p>
</div>
<div className="min-w-0">
<p className="truncate">{model.provider}</p>
<p className="text-muted-foreground">
{model.apiKeyPreview || 'no-key'}
</p>
</div>
<div className="min-w-0">
<p className="truncate" title={model.baseUrl}>
{model.host || model.baseUrl}
</p>
<p className="text-muted-foreground font-mono truncate">
{model.baseUrl}
</p>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="docs" className={tabContentClassName}>
<ScrollArea className="h-full">
<div className="space-y-4 pr-1">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<ShieldCheck className="h-4 w-4" />
Docs-Aligned Notes
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{docsNotes.map((note, index) => (
<p key={`${index}-${note}`} className="text-muted-foreground">
- {renderTextWithLinks(note)}
</p>
))}
<Separator />
<div className="space-y-2">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Factory Docs
</p>
<div className="space-y-1.5">
{docsLinks.map((link) => (
<a
key={link.id}
href={link.url}
target="_blank"
rel="noreferrer"
className="block rounded-md border px-2.5 py-2 transition-colors hover:bg-muted/50"
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{link.label}</span>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground">
{link.description}
</p>
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/90 underline underline-offset-2">
{link.url}
</p>
</a>
))}
</div>
</div>
<Separator />
<div className="space-y-2">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Provider Fact-Check Docs
</p>
<div className="space-y-1.5">
{providerDocs.map((providerDoc) => (
<a
key={`${providerDoc.provider}-${providerDoc.url}`}
href={providerDoc.url}
target="_blank"
rel="noreferrer"
className="block rounded-md border px-2.5 py-2 transition-colors hover:bg-muted/50"
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{providerDoc.label}</span>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground">
provider: {providerDoc.provider} | format: {providerDoc.apiFormat}
</p>
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/90 underline underline-offset-2">
{providerDoc.url}
</p>
</a>
))}
</div>
</div>
<Separator />
<p className="text-xs text-muted-foreground">
Provider values: {providerValues.join(', ')}
</p>
<p className="text-xs text-muted-foreground">
Settings hierarchy: {settingsHierarchy.join(' -> ')}
</p>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
</div>
</ScrollArea>
</Tabs>
);
};