feat(dashboard): enrich droid docs and quick settings controls

- add reusable compatible-CLI docs registry with external provider fact-check links

- render docs links in Droid notes panel and keep future CLI extension points

- add left-column quick controls for key settings.json fields (reasoning/autonomy/diff/automation)
This commit is contained in:
Tam Nhu Tran
2026-02-26 00:03:58 +07:00
parent 20e48b3dc0
commit 031dcd99c3
7 changed files with 519 additions and 28 deletions
@@ -0,0 +1,105 @@
export interface CompatibleCliDocLink {
id: string;
label: string;
url: string;
category: 'overview' | 'configuration' | 'byok' | 'reference';
source: 'factory' | 'provider';
description: string;
}
export interface CompatibleCliProviderDocLink {
provider: string;
label: string;
apiFormat: string;
url: string;
}
export interface CompatibleCliDocsReference {
providerValues: string[];
settingsHierarchy: string[];
notes: string[];
links: CompatibleCliDocLink[];
providerDocs: CompatibleCliProviderDocLink[];
}
interface CompatibleCliDocsRegistryEntry {
cliId: string;
displayName: string;
docsReference: CompatibleCliDocsReference;
}
const COMPATIBLE_CLI_DOCS_REGISTRY: Record<string, CompatibleCliDocsRegistryEntry> = {
droid: {
cliId: 'droid',
displayName: 'Droid CLI',
docsReference: {
providerValues: ['anthropic', 'openai', 'generic-chat-completion-api'],
settingsHierarchy: [
'project-level config',
'user-level config',
'home-level config',
'CLI flags and env vars',
],
notes: [
'BYOK custom models are read from ~/.factory/settings.json customModels[]',
'Factory docs mention legacy support for ~/.factory/config.json',
'Interactive model selection uses settings.model (custom:<alias>)',
'droid exec supports --model for one-off execution mode',
],
links: [
{
id: 'droid-cli-overview',
label: 'Droid CLI Overview',
url: 'https://docs.factory.ai/cli/',
category: 'overview',
source: 'factory',
description: 'Primary entry docs for setup, auth, and core CLI usage.',
},
{
id: 'droid-byok-overview',
label: 'BYOK Overview',
url: 'https://docs.factory.ai/cli/byok/overview/',
category: 'byok',
source: 'factory',
description: 'BYOK model/provider shape, provider values, and migration notes.',
},
{
id: 'droid-settings-reference',
label: 'settings.json Reference',
url: 'https://docs.factory.ai/cli/configuration/settings/',
category: 'configuration',
source: 'factory',
description: 'Supported settings keys, defaults, and allowed values.',
},
],
providerDocs: [
{
provider: 'anthropic',
label: 'Anthropic Messages API',
apiFormat: 'Messages API',
url: 'https://docs.anthropic.com/en/api/messages',
},
{
provider: 'openai',
label: 'OpenAI Responses API',
apiFormat: 'Responses API',
url: 'https://platform.openai.com/docs/api-reference/responses',
},
{
provider: 'generic-chat-completion-api',
label: 'OpenAI Chat Completions Spec',
apiFormat: 'Chat Completions API',
url: 'https://platform.openai.com/docs/api-reference/chat',
},
],
},
},
};
export function getCompatibleCliDocsReference(cliId: string): CompatibleCliDocsReference {
const entry = COMPATIBLE_CLI_DOCS_REGISTRY[cliId];
if (!entry) {
throw new Error(`Unsupported compatible CLI docs reference: ${cliId}`);
}
return entry.docsReference;
}
@@ -44,6 +44,30 @@ export interface DroidByokDiagnostics {
customModels: DroidCustomModelDiagnostics[];
}
export interface CompatibleCliDocLink {
id: string;
label: string;
url: string;
category: 'overview' | 'configuration' | 'byok' | 'reference';
source: 'factory' | 'provider';
description: string;
}
export interface CompatibleCliProviderDocLink {
provider: string;
label: string;
apiFormat: string;
url: string;
}
export interface CompatibleCliDocsReference {
providerValues: string[];
settingsHierarchy: string[];
notes: string[];
links: CompatibleCliDocLink[];
providerDocs: CompatibleCliProviderDocLink[];
}
export interface DroidDashboardDiagnostics {
binary: DroidBinaryDiagnostics;
files: {
@@ -52,11 +76,7 @@ export interface DroidDashboardDiagnostics {
};
byok: DroidByokDiagnostics;
warnings: string[];
docsReference: {
providerValues: string[];
settingsHierarchy: string[];
notes: string[];
};
docsReference: CompatibleCliDocsReference;
}
export interface DroidRawSettingsResponse {
@@ -14,6 +14,7 @@ import {
probeJsonObjectFile,
writeJsonObjectFileAtomic,
} from './compatible-cli-json-file-service';
import { getCompatibleCliDocsReference } from './compatible-cli-docs-registry';
interface DroidConfigPaths {
settingsPath: string;
@@ -160,6 +161,7 @@ export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByo
export async function getDroidDashboardDiagnostics(): Promise<DroidDashboardDiagnostics> {
const paths = resolveDroidConfigPaths();
const binaryPath = detectDroidCli();
const docsReference = getCompatibleCliDocsReference('droid');
const source = process.env.CCS_DROID_PATH ? 'CCS_DROID_PATH' : binaryPath ? 'PATH' : 'missing';
@@ -204,21 +206,7 @@ export async function getDroidDashboardDiagnostics(): Promise<DroidDashboardDiag
},
byok,
warnings,
docsReference: {
providerValues: ['anthropic', 'openai', 'generic-chat-completion-api'],
settingsHierarchy: [
'project-level config',
'user-level config',
'home-level config',
'CLI flags and env vars',
],
notes: [
'BYOK custom models are read from ~/.factory/settings.json customModels[]',
'Factory docs mention legacy support for ~/.factory/config.json',
'Interactive model selection uses settings.model (custom:<alias>)',
'droid exec supports --model for one-off execution mode',
],
},
docsReference,
};
}
@@ -5,6 +5,7 @@ import * as path from 'path';
import {
DroidRawSettingsConflictError,
DroidRawSettingsValidationError,
getDroidDashboardDiagnostics,
getDroidRawSettings,
maskApiKeyPreview,
resolveDroidConfigPaths,
@@ -110,6 +111,19 @@ describe('droid-dashboard-service', () => {
expect(raw.rawText).toContain('invalid-json');
});
it('includes structured docs links for fact-checking providers', async () => {
const diagnostics = await getDroidDashboardDiagnostics();
expect(diagnostics.docsReference.links.length).toBeGreaterThan(0);
expect(diagnostics.docsReference.providerDocs.length).toBeGreaterThan(0);
expect(diagnostics.docsReference.links.every((link) => link.url.startsWith('https://'))).toBe(
true
);
expect(
diagnostics.docsReference.providerDocs.some((doc) => doc.provider === 'anthropic')
).toBe(true);
});
it('saves valid raw settings content', async () => {
const result = await saveDroidRawSettings({
rawText: JSON.stringify({
@@ -0,0 +1,214 @@
import { SlidersHorizontal } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
const UNSET_VALUE = '__unset__';
type DroidEnumSettingKey = 'reasoningEffort' | 'autonomyLevel' | 'diffMode';
type DroidBooleanSettingKey =
| 'todoEnabled'
| 'todoAutoRefresh'
| 'autoCompactEnabled'
| 'soundEnabled';
type DroidNumberSettingKey = 'maxTurns' | 'maxToolCalls' | 'autoCompactThreshold';
export interface DroidQuickSettingsValues {
reasoningEffort: string | null;
autonomyLevel: string | null;
diffMode: string | null;
maxTurns: number | null;
maxToolCalls: number | null;
autoCompactThreshold: number | null;
todoEnabled: boolean | null;
todoAutoRefresh: boolean | null;
autoCompactEnabled: boolean | null;
soundEnabled: boolean | null;
}
interface DroidSettingsQuickControlsCardProps {
values: DroidQuickSettingsValues;
disabled: boolean;
disabledReason?: string | null;
onEnumSettingChange: (key: DroidEnumSettingKey, value: string | null) => void;
onBooleanSettingChange: (key: DroidBooleanSettingKey, value: boolean | null) => void;
onNumberSettingChange: (key: DroidNumberSettingKey, value: number | null) => void;
}
const enumFieldConfig: Array<{
key: DroidEnumSettingKey;
label: string;
description: string;
options: Array<{ value: string; label: string }>;
}> = [
{
key: 'reasoningEffort',
label: 'Reasoning Effort',
description: 'none | medium | high | max',
options: [
{ value: 'none', label: 'none' },
{ value: 'medium', label: 'medium' },
{ value: 'high', label: 'high' },
{ value: 'max', label: 'max' },
],
},
{
key: 'autonomyLevel',
label: 'Autonomy Level',
description: 'suggest | aggressive | full',
options: [
{ value: 'suggest', label: 'suggest' },
{ value: 'aggressive', label: 'aggressive' },
{ value: 'full', label: 'full' },
],
},
{
key: 'diffMode',
label: 'Diff Mode',
description: 'auto | none | inline | split',
options: [
{ value: 'auto', label: 'auto' },
{ value: 'none', label: 'none' },
{ value: 'inline', label: 'inline' },
{ value: 'split', label: 'split' },
],
},
];
const booleanFieldConfig: Array<{
key: DroidBooleanSettingKey;
label: string;
}> = [
{ key: 'todoEnabled', label: 'Todo Enabled' },
{ key: 'todoAutoRefresh', label: 'Todo Auto Refresh' },
{ key: 'autoCompactEnabled', label: 'Auto Compact Enabled' },
{ key: 'soundEnabled', label: 'Sound Enabled' },
];
const numberFieldConfig: Array<{
key: DroidNumberSettingKey;
label: string;
min: number;
step: number;
}> = [
{ key: 'maxTurns', label: 'Max Turns', min: 1, step: 1 },
{ key: 'maxToolCalls', label: 'Max Tool Calls', min: 1, step: 1 },
{ key: 'autoCompactThreshold', label: 'Auto Compact Threshold', min: 1000, step: 1000 },
];
function toBooleanSelectValue(value: boolean | null): string {
if (value === true) return 'true';
if (value === false) return 'false';
return UNSET_VALUE;
}
function toBooleanValue(value: string): boolean | null {
if (value === 'true') return true;
if (value === 'false') return false;
return null;
}
export function DroidSettingsQuickControlsCard({
values,
disabled,
disabledReason,
onEnumSettingChange,
onBooleanSettingChange,
onNumberSettingChange,
}: DroidSettingsQuickControlsCardProps) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<SlidersHorizontal className="h-4 w-4" />
Quick Settings
<Badge variant="outline" className="text-[10px] font-normal">
settings.json
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{disabledReason && <p className="text-xs text-amber-600">{disabledReason}</p>}
<div className="grid gap-3 sm:grid-cols-2">
{enumFieldConfig.map((field) => (
<div key={field.key} className="space-y-1">
<p className="text-xs font-medium">{field.label}</p>
<Select
value={values[field.key] ?? UNSET_VALUE}
onValueChange={(next) =>
onEnumSettingChange(field.key, next === UNSET_VALUE ? null : next)
}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Use default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET_VALUE}>Use default</SelectItem>
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">{field.description}</p>
</div>
))}
{numberFieldConfig.map((field) => (
<div key={field.key} className="space-y-1">
<p className="text-xs font-medium">{field.label}</p>
<Input
type="number"
min={field.min}
step={field.step}
value={values[field.key] ?? ''}
onChange={(event) => {
const nextRaw = event.target.value.trim();
if (!nextRaw) {
onNumberSettingChange(field.key, null);
return;
}
const next = Number.parseInt(nextRaw, 10);
if (!Number.isFinite(next)) return;
onNumberSettingChange(field.key, Math.max(field.min, next));
}}
className="h-8 text-xs"
disabled={disabled}
/>
</div>
))}
{booleanFieldConfig.map((field) => (
<div key={field.key} className="space-y-1">
<p className="text-xs font-medium">{field.label}</p>
<Select
value={toBooleanSelectValue(values[field.key])}
onValueChange={(next) => onBooleanSettingChange(field.key, toBooleanValue(next))}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Use default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET_VALUE}>Use default</SelectItem>
<SelectItem value="true">true</SelectItem>
<SelectItem value="false">false</SelectItem>
</SelectContent>
</Select>
</div>
))}
</div>
</CardContent>
</Card>
);
}
+18
View File
@@ -36,6 +36,22 @@ export interface DroidCustomModelDiagnostics {
apiKeyPreview: string | null;
}
export interface CompatibleCliDocLink {
id: string;
label: string;
url: string;
category: 'overview' | 'configuration' | 'byok' | 'reference';
source: 'factory' | 'provider';
description: string;
}
export interface CompatibleCliProviderDocLink {
provider: string;
label: string;
apiFormat: string;
url: string;
}
export interface DroidDashboardDiagnostics {
binary: DroidBinaryDiagnostics;
files: {
@@ -56,6 +72,8 @@ export interface DroidDashboardDiagnostics {
providerValues: string[];
settingsHierarchy: string[];
notes: string[];
links: CompatibleCliDocLink[];
providerDocs: CompatibleCliProviderDocLink[];
};
}
+140 -8
View File
@@ -4,6 +4,7 @@ import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import {
AlertTriangle,
CheckCircle2,
ExternalLink,
Folder,
GripVertical,
Loader2,
@@ -15,6 +16,10 @@ import {
import { useDroid } from '@/hooks/use-droid';
import { isApiConflictError } from '@/lib/api-client';
import { RawJsonSettingsEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel';
import {
DroidSettingsQuickControlsCard,
type DroidQuickSettingsValues,
} from '@/components/compatible-cli/droid-settings-quick-controls-card';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
@@ -33,18 +38,32 @@ function formatBytes(value: number | null | undefined): string {
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
}
function validateJsonObject(text: string): { valid: true } | { valid: false; error: string } {
function parseJsonObjectText(
text: string
): { valid: true; value: Record<string, unknown> } | { valid: false; error: string } {
try {
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { valid: false, error: 'JSON root must be an object.' };
}
return { valid: true };
return { valid: true, value: parsed as Record<string, unknown> };
} catch (error) {
return { valid: false, error: (error as Error).message };
}
}
function asStringValue(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
function asNumberValue(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function asBooleanValue(value: unknown): boolean | null {
return typeof value === 'boolean' ? value : null;
}
function DetailRow({
label,
value,
@@ -79,6 +98,59 @@ export function DroidPage() {
const rawBaseText = rawSettings?.rawText ?? '{}';
const rawEditorText = rawDraftText ?? rawBaseText;
const rawConfigDirty = rawDraftText !== null && rawDraftText !== rawBaseText;
const rawEditorParsed = parseJsonObjectText(rawEditorText);
const rawEditorValidation = rawEditorParsed.valid
? { valid: true as const }
: { valid: false as const, error: rawEditorParsed.error };
const setRawEditorDraftText = (nextText: string) => {
if (nextText === rawBaseText) {
setRawDraftText(null);
return;
}
setRawDraftText(nextText);
};
const updateSettingsField = (key: string, value: unknown | null) => {
if (!rawEditorParsed.valid) {
toast.error('Fix JSON syntax before using quick settings controls.');
return;
}
const nextSettings = { ...rawEditorParsed.value };
if (value === null || value === undefined) {
delete nextSettings[key];
} else {
nextSettings[key] = value;
}
setRawEditorDraftText(JSON.stringify(nextSettings, null, 2) + '\n');
};
const quickSettingsValues: DroidQuickSettingsValues = rawEditorParsed.valid
? {
reasoningEffort: asStringValue(rawEditorParsed.value.reasoningEffort),
autonomyLevel: asStringValue(rawEditorParsed.value.autonomyLevel),
diffMode: asStringValue(rawEditorParsed.value.diffMode),
maxTurns: asNumberValue(rawEditorParsed.value.maxTurns),
maxToolCalls: asNumberValue(rawEditorParsed.value.maxToolCalls),
autoCompactThreshold: asNumberValue(rawEditorParsed.value.autoCompactThreshold),
todoEnabled: asBooleanValue(rawEditorParsed.value.todoEnabled),
todoAutoRefresh: asBooleanValue(rawEditorParsed.value.todoAutoRefresh),
autoCompactEnabled: asBooleanValue(rawEditorParsed.value.autoCompactEnabled),
soundEnabled: asBooleanValue(rawEditorParsed.value.soundEnabled),
}
: {
reasoningEffort: null,
autonomyLevel: null,
diffMode: null,
maxTurns: null,
maxToolCalls: null,
autoCompactThreshold: null,
todoEnabled: null,
todoAutoRefresh: null,
autoCompactEnabled: null,
soundEnabled: null,
};
const refreshAll = async () => {
await Promise.all([refetchDiagnostics(), refetchRawSettings()]);
@@ -112,7 +184,6 @@ export function DroidPage() {
() => Object.entries(diagnostics?.byok.providerBreakdown ?? {}).sort((a, b) => b[1] - a[1]),
[diagnostics?.byok.providerBreakdown]
);
const rawEditorValidation = validateJsonObject(rawEditorText);
const renderOverview = () => {
if (diagnosticsLoading) {
@@ -202,6 +273,23 @@ export function DroidPage() {
</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);
}}
/>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
@@ -255,6 +343,54 @@ export function DroidPage() {
</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">
{diagnostics.docsReference.links.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>
</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">
{diagnostics.docsReference.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>
</a>
))}
</div>
</div>
<Separator />
<p className="text-xs text-muted-foreground">
Provider values: {diagnostics.docsReference.providerValues.join(', ')}
</p>
@@ -358,11 +494,7 @@ export function DroidPage() {
!rawEditorValidation.valid
}
onChange={(next) => {
if (next === rawBaseText) {
setRawDraftText(null);
return;
}
setRawDraftText(next);
setRawEditorDraftText(next);
}}
onSave={handleSaveRawSettings}
onRefresh={refreshAll}