From bc079bc88656435da1d5b4b2329f89c90c910d1b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 22:59:15 +0700 Subject: [PATCH 1/7] feat(dashboard): add dedicated Factory Droid diagnostics page - add /droid route under a new Compatible CLIs sidebar section - expose /api/droid/diagnostics and /api/droid/settings/raw for install and BYOK visibility - include read-only settings.json viewer plus model/provider breakdown - add unit coverage for droid dashboard service parsing and path logic --- README.md | 3 + src/web-server/routes/droid-routes.ts | 34 ++ src/web-server/routes/index.ts | 4 + .../services/compatible-cli-types.ts | 70 ++++ .../services/droid-dashboard-service.ts | 298 ++++++++++++++ .../droid-dashboard-service.test.ts | 114 ++++++ ui/src/App.tsx | 9 + ui/src/components/layout/app-sidebar.tsx | 5 + ui/src/hooks/use-droid.ts | 119 ++++++ ui/src/pages/droid.tsx | 380 ++++++++++++++++++ ui/src/pages/index.tsx | 2 + 11 files changed, 1038 insertions(+) create mode 100644 src/web-server/routes/droid-routes.ts create mode 100644 src/web-server/services/compatible-cli-types.ts create mode 100644 src/web-server/services/droid-dashboard-service.ts create mode 100644 tests/unit/web-server/droid-dashboard-service.test.ts create mode 100644 ui/src/hooks/use-droid.ts create mode 100644 ui/src/pages/droid.tsx diff --git a/README.md b/README.md index 0245c4a9..cca63796 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ The dashboard provides visual management for all account types: - **Claude Accounts**: Create isolated instances (work, personal, client) - **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot - **API Profiles**: Configure GLM, Kimi with your keys +- **Factory Droid**: Track Droid install location and BYOK settings health - **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations) - **Health Monitor**: Real-time status across all profiles @@ -166,6 +167,8 @@ CCS also persists Droid's active model selector in `~/.factory/settings.json` (`model: custom:`). This avoids passing `-m` argv in interactive mode, which Droid treats as queued prompt text. +Dashboard parity: `ccs config` -> `Factory Droid` + ### Per-Profile Target Defaults You can pin a default target (`claude` or `droid`) per profile: diff --git a/src/web-server/routes/droid-routes.ts b/src/web-server/routes/droid-routes.ts new file mode 100644 index 00000000..0bbb19e5 --- /dev/null +++ b/src/web-server/routes/droid-routes.ts @@ -0,0 +1,34 @@ +import type { Request, Response } from 'express'; +import { Router } from 'express'; +import { + getDroidDashboardDiagnostics, + getDroidRawSettings, +} from '../services/droid-dashboard-service'; + +const router = Router(); + +/** + * GET /api/droid/diagnostics + * Dashboard-ready Droid installation + BYOK configuration diagnostics. + */ +router.get('/diagnostics', (_req: Request, res: Response): void => { + try { + res.json(getDroidDashboardDiagnostics()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/droid/settings/raw + * Raw ~/.factory/settings.json payload for read-only viewer. + */ +router.get('/settings/raw', (_req: Request, res: Response): void => { + try { + res.json(getDroidRawSettings()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index f9517635..09097156 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -21,6 +21,7 @@ import cliproxyStatsRoutes from './cliproxy-stats-routes'; import cliproxySyncRoutes from './cliproxy-sync-routes'; import copilotRoutes from './copilot-routes'; import cursorRoutes from './cursor-routes'; +import droidRoutes from './droid-routes'; import miscRoutes from './misc-routes'; import cliproxyServerRoutes from './proxy-routes'; import authRoutes from './auth-routes'; @@ -67,6 +68,9 @@ apiRoutes.use('/copilot', copilotRoutes); // ==================== Cursor ==================== apiRoutes.use('/cursor', cursorRoutes); +// ==================== Droid ==================== +apiRoutes.use('/droid', droidRoutes); + // ==================== CLIProxy Server Settings ==================== apiRoutes.use('/cliproxy-server', cliproxyServerRoutes); diff --git a/src/web-server/services/compatible-cli-types.ts b/src/web-server/services/compatible-cli-types.ts new file mode 100644 index 00000000..6493f783 --- /dev/null +++ b/src/web-server/services/compatible-cli-types.ts @@ -0,0 +1,70 @@ +export type DroidBinarySource = 'CCS_DROID_PATH' | 'PATH' | 'missing'; + +export interface DroidBinaryDiagnostics { + installed: boolean; + path: string | null; + installDir: string | null; + source: DroidBinarySource; + version: string | null; + overridePath: string | null; +} + +export interface DroidConfigFileDiagnostics { + label: string; + path: string; + resolvedPath: string; + exists: boolean; + isSymlink: boolean; + isRegularFile: boolean; + sizeBytes: number | null; + mtimeMs: number | null; + parseError: string | null; + readError: string | null; +} + +export interface DroidCustomModelDiagnostics { + displayName: string; + model: string; + provider: string; + baseUrl: string; + host: string | null; + maxOutputTokens: number | null; + isCcsManaged: boolean; + apiKeyState: 'set' | 'missing'; + apiKeyPreview: string | null; +} + +export interface DroidByokDiagnostics { + activeModelSelector: string | null; + customModelCount: number; + ccsManagedCount: number; + userManagedCount: number; + invalidModelEntryCount: number; + providerBreakdown: Record; + customModels: DroidCustomModelDiagnostics[]; +} + +export interface DroidDashboardDiagnostics { + binary: DroidBinaryDiagnostics; + files: { + settings: DroidConfigFileDiagnostics; + globalConfig: DroidConfigFileDiagnostics; + }; + byok: DroidByokDiagnostics; + warnings: string[]; + docsReference: { + providerValues: string[]; + settingsHierarchy: string[]; + notes: string[]; + }; +} + +export interface DroidRawSettingsResponse { + path: string; + resolvedPath: string; + exists: boolean; + mtime: number; + rawText: string; + settings: Record | null; + parseError: string | null; +} diff --git a/src/web-server/services/droid-dashboard-service.ts b/src/web-server/services/droid-dashboard-service.ts new file mode 100644 index 00000000..050bffd9 --- /dev/null +++ b/src/web-server/services/droid-dashboard-service.ts @@ -0,0 +1,298 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { execFileSync } from 'child_process'; +import { detectDroidCli } from '../../targets/droid-detector'; +import type { + DroidByokDiagnostics, + DroidConfigFileDiagnostics, + DroidCustomModelDiagnostics, + DroidDashboardDiagnostics, + DroidRawSettingsResponse, +} from './compatible-cli-types'; + +interface DroidConfigPaths { + settingsPath: string; + settingsDisplayPath: string; + globalConfigPath: string; + globalConfigDisplayPath: string; +} + +interface JsonFileProbe { + diagnostics: DroidConfigFileDiagnostics; + json: Record | null; + rawText: string; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function parseHost(value: string): string | null { + try { + return new URL(value).host || null; + } catch { + return null; + } +} + +export function maskApiKeyPreview(value: string): string { + if (!value) return ''; + const suffix = value.slice(-4); + return `***${suffix}`; +} + +function isCcsManagedDisplayName(displayName: string): boolean { + return displayName.startsWith('CCS ') || displayName.startsWith('ccs-'); +} + +export function resolveDroidConfigPaths( + options: { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + homeDir?: string; + } = {} +): DroidConfigPaths { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? os.homedir(); + + const byokBase = env.CCS_HOME || homeDir; + const settingsPath = path.join(byokBase, '.factory', 'settings.json'); + + const globalConfigRoot = + platform === 'win32' + ? env.APPDATA || path.join(homeDir, 'AppData', 'Roaming') + : env.XDG_CONFIG_HOME || path.join(homeDir, '.config'); + const globalConfigPath = path.join(globalConfigRoot, 'factory', 'config.json'); + + return { + settingsPath, + settingsDisplayPath: '~/.factory/settings.json', + globalConfigPath, + globalConfigDisplayPath: + platform === 'win32' ? '%APPDATA%/factory/config.json' : '~/.config/factory/config.json', + }; +} + +function getBinaryVersion(binaryPath: string): string | null { + try { + return execFileSync(binaryPath, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + }) + .trim() + .split('\n')[0] + .trim(); + } catch { + return null; + } +} + +function readJsonFileProbe(filePath: string, label: string, displayPath: string): JsonFileProbe { + if (!fs.existsSync(filePath)) { + return { + diagnostics: { + label, + path: displayPath, + resolvedPath: filePath, + exists: false, + isSymlink: false, + isRegularFile: false, + sizeBytes: null, + mtimeMs: null, + parseError: null, + readError: null, + }, + json: null, + rawText: '{}', + }; + } + + const stat = fs.lstatSync(filePath); + const diagnostics: DroidConfigFileDiagnostics = { + label, + path: displayPath, + resolvedPath: filePath, + exists: true, + isSymlink: stat.isSymbolicLink(), + isRegularFile: stat.isFile(), + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + parseError: null, + readError: null, + }; + + if (diagnostics.isSymlink) { + diagnostics.readError = 'Refusing symlink file for safety.'; + return { diagnostics, json: null, rawText: '{}' }; + } + + if (!diagnostics.isRegularFile) { + diagnostics.readError = 'Target is not a regular file.'; + return { diagnostics, json: null, rawText: '{}' }; + } + + try { + const rawText = fs.readFileSync(filePath, 'utf8'); + try { + const parsed = JSON.parse(rawText); + if (!isObject(parsed)) { + diagnostics.parseError = 'JSON root must be an object.'; + return { diagnostics, json: null, rawText }; + } + return { diagnostics, json: parsed, rawText }; + } catch (error) { + diagnostics.parseError = (error as Error).message; + return { diagnostics, json: null, rawText }; + } + } catch (error) { + diagnostics.readError = (error as Error).message; + return { diagnostics, json: null, rawText: '{}' }; + } +} + +export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByokDiagnostics { + const rows: DroidCustomModelDiagnostics[] = []; + const providerBreakdown: Record = {}; + let invalidModelEntryCount = 0; + + const source = Array.isArray(customModelsValue) + ? customModelsValue + : isObject(customModelsValue) + ? Object.values(customModelsValue) + : []; + + for (const item of source) { + if (!isObject(item)) { + invalidModelEntryCount += 1; + continue; + } + + const displayName = asString(item.displayName); + const model = asString(item.model); + const baseUrl = asString(item.baseUrl); + const providerRaw = asString(item.provider); + const apiKey = asString(item.apiKey); + + if (!displayName || !model || !baseUrl || !providerRaw) { + invalidModelEntryCount += 1; + continue; + } + + const provider = providerRaw.toLowerCase(); + providerBreakdown[provider] = (providerBreakdown[provider] ?? 0) + 1; + + rows.push({ + displayName, + model, + provider, + baseUrl, + host: parseHost(baseUrl), + maxOutputTokens: typeof item.maxOutputTokens === 'number' ? item.maxOutputTokens : null, + isCcsManaged: isCcsManagedDisplayName(displayName), + apiKeyState: apiKey ? 'set' : 'missing', + apiKeyPreview: apiKey ? maskApiKeyPreview(apiKey) : null, + }); + } + + const ccsManagedCount = rows.filter((row) => row.isCcsManaged).length; + + return { + activeModelSelector: null, + customModelCount: rows.length, + ccsManagedCount, + userManagedCount: rows.length - ccsManagedCount, + invalidModelEntryCount, + providerBreakdown, + customModels: rows, + }; +} + +export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { + const paths = resolveDroidConfigPaths(); + const binaryPath = detectDroidCli(); + + const source = process.env.CCS_DROID_PATH ? 'CCS_DROID_PATH' : binaryPath ? 'PATH' : 'missing'; + + const settingsProbe = readJsonFileProbe( + paths.settingsPath, + 'BYOK settings', + paths.settingsDisplayPath + ); + const globalConfigProbe = readJsonFileProbe( + paths.globalConfigPath, + 'Global config', + paths.globalConfigDisplayPath + ); + + const byok = summarizeDroidCustomModels(settingsProbe.json?.customModels); + byok.activeModelSelector = asString(settingsProbe.json?.model); + + const warnings: string[] = []; + if (!binaryPath) warnings.push('Droid binary is not detected in PATH or CCS_DROID_PATH.'); + if (settingsProbe.diagnostics.parseError) { + warnings.push('~/.factory/settings.json contains invalid JSON.'); + } + if (byok.invalidModelEntryCount > 0) { + warnings.push(`${byok.invalidModelEntryCount} customModels entries are malformed.`); + } + if (globalConfigProbe.diagnostics.parseError) { + warnings.push('Global Droid config JSON is invalid.'); + } + + return { + binary: { + installed: !!binaryPath, + path: binaryPath, + installDir: binaryPath ? path.dirname(binaryPath) : null, + source, + version: binaryPath ? getBinaryVersion(binaryPath) : null, + overridePath: process.env.CCS_DROID_PATH || null, + }, + files: { + settings: settingsProbe.diagnostics, + globalConfig: globalConfigProbe.diagnostics, + }, + 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[]', + 'Interactive model selection uses settings.model (custom:)', + 'droid exec supports --model for one-off execution mode', + ], + }, + }; +} + +export function getDroidRawSettings(): DroidRawSettingsResponse { + const paths = resolveDroidConfigPaths(); + const settingsProbe = readJsonFileProbe( + paths.settingsPath, + 'BYOK settings', + paths.settingsDisplayPath + ); + + return { + path: paths.settingsDisplayPath, + resolvedPath: paths.settingsPath, + exists: settingsProbe.diagnostics.exists, + mtime: settingsProbe.diagnostics.mtimeMs ?? Date.now(), + rawText: settingsProbe.rawText, + settings: settingsProbe.json, + parseError: settingsProbe.diagnostics.parseError, + }; +} diff --git a/tests/unit/web-server/droid-dashboard-service.test.ts b/tests/unit/web-server/droid-dashboard-service.test.ts new file mode 100644 index 00000000..23249fe3 --- /dev/null +++ b/tests/unit/web-server/droid-dashboard-service.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + getDroidRawSettings, + maskApiKeyPreview, + resolveDroidConfigPaths, + summarizeDroidCustomModels, +} from '../../../src/web-server/services/droid-dashboard-service'; + +const testRoot = path.join(os.tmpdir(), `ccs-droid-dashboard-test-${Date.now()}`); + +beforeEach(() => { + fs.mkdirSync(testRoot, { recursive: true }); + process.env.CCS_HOME = testRoot; +}); + +afterEach(() => { + delete process.env.CCS_HOME; + if (fs.existsSync(testRoot)) { + fs.rmSync(testRoot, { recursive: true, force: true }); + } +}); + +describe('droid-dashboard-service', () => { + it('resolves droid config paths on unix-like platforms', () => { + const resolved = resolveDroidConfigPaths({ + platform: 'darwin', + env: { + CCS_HOME: '/tmp/ccs-home', + XDG_CONFIG_HOME: '/tmp/xdg', + } as NodeJS.ProcessEnv, + homeDir: '/Users/tester', + }); + + expect(resolved.settingsPath).toBe('/tmp/ccs-home/.factory/settings.json'); + expect(resolved.globalConfigPath).toBe('/tmp/xdg/factory/config.json'); + expect(resolved.settingsDisplayPath).toBe('~/.factory/settings.json'); + expect(resolved.globalConfigDisplayPath).toBe('~/.config/factory/config.json'); + }); + + it('resolves droid config paths on windows platforms', () => { + const resolved = resolveDroidConfigPaths({ + platform: 'win32', + env: { + APPDATA: 'C:/Users/test/AppData/Roaming', + } as NodeJS.ProcessEnv, + homeDir: 'C:/Users/test', + }); + + expect(resolved.settingsPath).toBe(path.join('C:/Users/test', '.factory', 'settings.json')); + expect(resolved.globalConfigPath).toBe( + path.join('C:/Users/test/AppData/Roaming', 'factory', 'config.json') + ); + expect(resolved.globalConfigDisplayPath).toBe('%APPDATA%/factory/config.json'); + }); + + it('masks api key preview with only suffix', () => { + expect(maskApiKeyPreview('sk-abcdefghijklmnop')).toBe('***mnop'); + }); + + it('summarizes custom model entries with provider breakdown and ownership', () => { + const summary = summarizeDroidCustomModels([ + { + displayName: 'CCS codex', + model: 'gpt-5-codex', + baseUrl: 'http://127.0.0.1:8317/v1', + apiKey: 'secret-token-1234', + provider: 'openai', + }, + { + displayName: 'Factory team profile', + model: 'claude-sonnet-4-5', + baseUrl: 'https://api.anthropic.com', + apiKey: 'another-token-9999', + provider: 'anthropic', + }, + { + displayName: 'bad entry', + }, + ]); + + expect(summary.customModelCount).toBe(2); + expect(summary.ccsManagedCount).toBe(1); + expect(summary.userManagedCount).toBe(1); + expect(summary.invalidModelEntryCount).toBe(1); + expect(summary.providerBreakdown.openai).toBe(1); + expect(summary.providerBreakdown.anthropic).toBe(1); + expect(summary.customModels[0].apiKeyPreview).toBe('***1234'); + }); + + it('returns raw settings payload for missing settings file', () => { + const raw = getDroidRawSettings(); + + expect(raw.exists).toBe(false); + expect(raw.path).toBe('~/.factory/settings.json'); + expect(raw.rawText).toBe('{}'); + expect(raw.settings).toBeNull(); + }); + + it('returns parseError when settings.json is invalid JSON', () => { + const settingsDir = path.join(testRoot, '.factory'); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync(path.join(settingsDir, 'settings.json'), '{ invalid-json'); + + const raw = getDroidRawSettings(); + + expect(raw.exists).toBe(true); + expect(raw.parseError).toBeString(); + expect(raw.settings).toBeNull(); + expect(raw.rawText).toContain('invalid-json'); + }); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 4a823ae4..62ecb304 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -27,6 +27,7 @@ const CliproxyControlPanelPage = lazy(() => ); const CopilotPage = lazy(() => import('@/pages/copilot').then((m) => ({ default: m.CopilotPage }))); const CursorPage = lazy(() => import('@/pages/cursor').then((m) => ({ default: m.CursorPage }))); +const DroidPage = lazy(() => import('@/pages/droid').then((m) => ({ default: m.DroidPage }))); const AccountsPage = lazy(() => import('@/pages/accounts').then((m) => ({ default: m.AccountsPage })) ); @@ -117,6 +118,14 @@ export default function App() { } /> + }> + + + } + /> ; + customModels: DroidCustomModelDiagnostics[]; + }; + warnings: string[]; + docsReference: { + providerValues: string[]; + settingsHierarchy: string[]; + notes: string[]; + }; +} + +export interface DroidRawSettings { + path: string; + resolvedPath: string; + exists: boolean; + mtime: number; + rawText: string; + settings: Record | null; + parseError: string | null; +} + +async function fetchDroidDiagnostics(): Promise { + const res = await fetch(withApiBase('/droid/diagnostics')); + if (!res.ok) throw new Error('Failed to fetch Droid diagnostics'); + return res.json(); +} + +async function fetchDroidRawSettings(): Promise { + const res = await fetch(withApiBase('/droid/settings/raw')); + if (!res.ok) throw new Error('Failed to fetch Droid raw settings'); + return res.json(); +} + +export function useDroid() { + const diagnosticsQuery = useQuery({ + queryKey: ['droid-diagnostics'], + queryFn: fetchDroidDiagnostics, + refetchInterval: 10000, + }); + + const rawSettingsQuery = useQuery({ + queryKey: ['droid-raw-settings'], + queryFn: fetchDroidRawSettings, + }); + + return useMemo( + () => ({ + diagnostics: diagnosticsQuery.data, + diagnosticsLoading: diagnosticsQuery.isLoading, + diagnosticsError: diagnosticsQuery.error, + refetchDiagnostics: diagnosticsQuery.refetch, + + rawSettings: rawSettingsQuery.data, + rawSettingsLoading: rawSettingsQuery.isLoading, + rawSettingsError: rawSettingsQuery.error, + refetchRawSettings: rawSettingsQuery.refetch, + }), + [ + diagnosticsQuery.data, + diagnosticsQuery.isLoading, + diagnosticsQuery.error, + diagnosticsQuery.refetch, + rawSettingsQuery.data, + rawSettingsQuery.isLoading, + rawSettingsQuery.error, + rawSettingsQuery.refetch, + ] + ); +} diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx new file mode 100644 index 00000000..5792ef09 --- /dev/null +++ b/ui/src/pages/droid.tsx @@ -0,0 +1,380 @@ +import { useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { + AlertTriangle, + CheckCircle2, + Copy, + FileCode2, + Folder, + GripVertical, + Loader2, + RefreshCw, + Server, + ShieldCheck, + TerminalSquare, + XCircle, +} from 'lucide-react'; +import { useDroid } from '@/hooks/use-droid'; +import { Button } from '@/components/ui/button'; +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 { CodeEditor } from '@/components/shared/code-editor'; +import { cn } from '@/lib/utils'; + +function formatTimestamp(value: number | null | undefined): string { + if (!value || !Number.isFinite(value)) return 'N/A'; + return new Date(value).toLocaleString(); +} + +function formatBytes(value: number | null | undefined): string { + if (!value || value <= 0) return '0 B'; + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / (1024 * 1024)).toFixed(2)} MB`; +} + +function DetailRow({ + label, + value, + mono = false, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
+ {label} + {value} +
+ ); +} + +export function DroidPage() { + const { + diagnostics, + diagnosticsLoading, + diagnosticsError, + refetchDiagnostics, + rawSettings, + rawSettingsLoading, + refetchRawSettings, + } = useDroid(); + + const [copied, setCopied] = useState(false); + + const copyRawSettings = async () => { + if (!rawSettings?.rawText) return; + await navigator.clipboard.writeText(rawSettings.rawText); + setCopied(true); + toast.success('Droid settings copied to clipboard'); + window.setTimeout(() => setCopied(false), 1500); + }; + + const refreshAll = async () => { + await Promise.all([refetchDiagnostics(), refetchRawSettings()]); + }; + + const customModels = diagnostics?.byok.customModels ?? []; + const providerRows = useMemo( + () => Object.entries(diagnostics?.byok.providerBreakdown ?? {}).sort((a, b) => b[1] - a[1]), + [diagnostics?.byok.providerBreakdown] + ); + + const renderOverview = () => { + if (diagnosticsLoading) { + return ( +
+ + Loading Droid diagnostics... +
+ ); + } + + if (diagnosticsError || !diagnostics) { + return ( +
+ Failed to load Droid diagnostics. +
+ ); + } + + return ( + +
+ + + + + Runtime & Installation + + + +
+ Status + + {diagnostics.binary.installed ? 'Detected' : 'Not Found'} + +
+ + + + + +
+
+ + + + + + Config Files + + + + {[diagnostics.files.settings, diagnostics.files.globalConfig].map((file) => ( +
+
+ {file.label} + {file.exists ? ( + + ) : ( + + )} +
+ + + + + {file.parseError && ( +

Parse warning: {file.parseError}

+ )} + {file.readError && ( +

Read warning: {file.readError}

+ )} +
+ ))} +
+
+ + + + + + BYOK Summary + + + + + + + + + +
+

Providers

+
+ {providerRows.length === 0 && ( + + none + + )} + {providerRows.map(([provider, count]) => ( + + {provider}: {count} + + ))} +
+
+
+
+ + + + + + Docs-Aligned Notes + + + + {diagnostics.docsReference.notes.map((note) => ( +

+ - {note} +

+ ))} + +

+ Provider values: {diagnostics.docsReference.providerValues.join(', ')} +

+

+ Settings hierarchy: {diagnostics.docsReference.settingsHierarchy.join(' -> ')} +

+
+
+ + + + Custom Models + + +
+
+ Name / Model + Provider + Base URL +
+ +
+ {customModels.length === 0 && ( +
+ No custom models +
+ )} + {customModels.map((model) => ( +
+
+

{model.displayName}

+

{model.model}

+
+
+

{model.provider}

+

{model.apiKeyPreview || 'no-key'}

+
+
+

+ {model.host || model.baseUrl} +

+

+ {model.baseUrl} +

+
+
+ ))} +
+
+
+
+
+ + {diagnostics.warnings.length > 0 && ( + + + + + Warnings + + + + {diagnostics.warnings.map((warning) => ( +

+ - {warning} +

+ ))} +
+
+ )} +
+
+ ); + }; + + return ( +
+ + +
{renderOverview()}
+
+ + + + +
+
+
+

+ + Droid BYOK Settings +

+

+ {rawSettings?.path || '~/.factory/settings.json'} +

+
+
+ + +
+
+ +
+ {rawSettingsLoading ? ( +
+ + Loading settings.json... +
+ ) : ( +
+ {rawSettings?.parseError && ( +
+ Parse warning: {rawSettings.parseError} +
+ )} +
+
+ {}} + language="json" + readonly + minHeight="100%" + /> +
+
+
+ )} +
+
+
+
+
+ ); +} diff --git a/ui/src/pages/index.tsx b/ui/src/pages/index.tsx index dda9ae12..b4ef665f 100644 --- a/ui/src/pages/index.tsx +++ b/ui/src/pages/index.tsx @@ -17,3 +17,5 @@ export { AnalyticsPage } from './analytics'; export { CursorPage } from './cursor'; export { UpdatesPage } from './updates'; + +export { DroidPage } from './droid'; From c6d2e71ec2f4e2587b13e1bb7d01d53499cd0cdf Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 23:26:23 +0700 Subject: [PATCH 2/7] fix(dashboard): align droid config diagnostics with factory paths - replace ~/.config/factory/config.json diagnostics with legacy ~/.factory/config.json - keep ~/.factory/settings.json as the primary BYOK source in UI + API payload - update droid dashboard tests and frontend typings for legacyConfig --- .../services/compatible-cli-types.ts | 2 +- .../services/droid-dashboard-service.ts | 32 ++++++++----------- .../droid-dashboard-service.test.ts | 15 +++------ ui/src/hooks/use-droid.ts | 2 +- ui/src/pages/droid.tsx | 2 +- 5 files changed, 21 insertions(+), 32 deletions(-) diff --git a/src/web-server/services/compatible-cli-types.ts b/src/web-server/services/compatible-cli-types.ts index 6493f783..bb9898e2 100644 --- a/src/web-server/services/compatible-cli-types.ts +++ b/src/web-server/services/compatible-cli-types.ts @@ -48,7 +48,7 @@ export interface DroidDashboardDiagnostics { binary: DroidBinaryDiagnostics; files: { settings: DroidConfigFileDiagnostics; - globalConfig: DroidConfigFileDiagnostics; + legacyConfig: DroidConfigFileDiagnostics; }; byok: DroidByokDiagnostics; warnings: string[]; diff --git a/src/web-server/services/droid-dashboard-service.ts b/src/web-server/services/droid-dashboard-service.ts index 050bffd9..d342c875 100644 --- a/src/web-server/services/droid-dashboard-service.ts +++ b/src/web-server/services/droid-dashboard-service.ts @@ -14,8 +14,8 @@ import type { interface DroidConfigPaths { settingsPath: string; settingsDisplayPath: string; - globalConfigPath: string; - globalConfigDisplayPath: string; + legacyConfigPath: string; + legacyConfigDisplayPath: string; } interface JsonFileProbe { @@ -57,25 +57,18 @@ export function resolveDroidConfigPaths( homeDir?: string; } = {} ): DroidConfigPaths { - const platform = options.platform ?? process.platform; const env = options.env ?? process.env; const homeDir = options.homeDir ?? os.homedir(); const byokBase = env.CCS_HOME || homeDir; const settingsPath = path.join(byokBase, '.factory', 'settings.json'); - - const globalConfigRoot = - platform === 'win32' - ? env.APPDATA || path.join(homeDir, 'AppData', 'Roaming') - : env.XDG_CONFIG_HOME || path.join(homeDir, '.config'); - const globalConfigPath = path.join(globalConfigRoot, 'factory', 'config.json'); + const legacyConfigPath = path.join(byokBase, '.factory', 'config.json'); return { settingsPath, settingsDisplayPath: '~/.factory/settings.json', - globalConfigPath, - globalConfigDisplayPath: - platform === 'win32' ? '%APPDATA%/factory/config.json' : '~/.config/factory/config.json', + legacyConfigPath, + legacyConfigDisplayPath: '~/.factory/config.json', }; } @@ -225,10 +218,10 @@ export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { 'BYOK settings', paths.settingsDisplayPath ); - const globalConfigProbe = readJsonFileProbe( - paths.globalConfigPath, - 'Global config', - paths.globalConfigDisplayPath + const legacyConfigProbe = readJsonFileProbe( + paths.legacyConfigPath, + 'Legacy config', + paths.legacyConfigDisplayPath ); const byok = summarizeDroidCustomModels(settingsProbe.json?.customModels); @@ -242,8 +235,8 @@ export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { if (byok.invalidModelEntryCount > 0) { warnings.push(`${byok.invalidModelEntryCount} customModels entries are malformed.`); } - if (globalConfigProbe.diagnostics.parseError) { - warnings.push('Global Droid config JSON is invalid.'); + if (legacyConfigProbe.diagnostics.parseError) { + warnings.push('Legacy Droid config (~/.factory/config.json) JSON is invalid.'); } return { @@ -257,7 +250,7 @@ export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { }, files: { settings: settingsProbe.diagnostics, - globalConfig: globalConfigProbe.diagnostics, + legacyConfig: legacyConfigProbe.diagnostics, }, byok, warnings, @@ -271,6 +264,7 @@ export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { ], 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:)', 'droid exec supports --model for one-off execution mode', ], diff --git a/tests/unit/web-server/droid-dashboard-service.test.ts b/tests/unit/web-server/droid-dashboard-service.test.ts index 23249fe3..f1f388a7 100644 --- a/tests/unit/web-server/droid-dashboard-service.test.ts +++ b/tests/unit/web-server/droid-dashboard-service.test.ts @@ -29,31 +29,26 @@ describe('droid-dashboard-service', () => { platform: 'darwin', env: { CCS_HOME: '/tmp/ccs-home', - XDG_CONFIG_HOME: '/tmp/xdg', } as NodeJS.ProcessEnv, homeDir: '/Users/tester', }); expect(resolved.settingsPath).toBe('/tmp/ccs-home/.factory/settings.json'); - expect(resolved.globalConfigPath).toBe('/tmp/xdg/factory/config.json'); + expect(resolved.legacyConfigPath).toBe('/tmp/ccs-home/.factory/config.json'); expect(resolved.settingsDisplayPath).toBe('~/.factory/settings.json'); - expect(resolved.globalConfigDisplayPath).toBe('~/.config/factory/config.json'); + expect(resolved.legacyConfigDisplayPath).toBe('~/.factory/config.json'); }); it('resolves droid config paths on windows platforms', () => { const resolved = resolveDroidConfigPaths({ platform: 'win32', - env: { - APPDATA: 'C:/Users/test/AppData/Roaming', - } as NodeJS.ProcessEnv, + env: {} as NodeJS.ProcessEnv, homeDir: 'C:/Users/test', }); expect(resolved.settingsPath).toBe(path.join('C:/Users/test', '.factory', 'settings.json')); - expect(resolved.globalConfigPath).toBe( - path.join('C:/Users/test/AppData/Roaming', 'factory', 'config.json') - ); - expect(resolved.globalConfigDisplayPath).toBe('%APPDATA%/factory/config.json'); + expect(resolved.legacyConfigPath).toBe(path.join('C:/Users/test', '.factory', 'config.json')); + expect(resolved.legacyConfigDisplayPath).toBe('~/.factory/config.json'); }); it('masks api key preview with only suffix', () => { diff --git a/ui/src/hooks/use-droid.ts b/ui/src/hooks/use-droid.ts index ec939b50..2a647f6c 100644 --- a/ui/src/hooks/use-droid.ts +++ b/ui/src/hooks/use-droid.ts @@ -40,7 +40,7 @@ export interface DroidDashboardDiagnostics { binary: DroidBinaryDiagnostics; files: { settings: DroidConfigFileDiagnostics; - globalConfig: DroidConfigFileDiagnostics; + legacyConfig: DroidConfigFileDiagnostics; }; byok: { activeModelSelector: string | null; diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx index 5792ef09..3f111297 100644 --- a/ui/src/pages/droid.tsx +++ b/ui/src/pages/droid.tsx @@ -147,7 +147,7 @@ export function DroidPage() { - {[diagnostics.files.settings, diagnostics.files.globalConfig].map((file) => ( + {[diagnostics.files.settings, diagnostics.files.legacyConfig].map((file) => (
{file.label} From e9eab712b3f60d1dcf45746df69dabac29beb299 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 23:39:39 +0700 Subject: [PATCH 3/7] refactor(dashboard): reuse compatible CLI settings editor stack - add shared backend JSON file probe/write utilities for compatible CLI settings - add shared frontend raw JSON editor panel and wire Droid page to it - support PUT save flow with validation and mtime conflict handling --- src/web-server/routes/droid-routes.ts | 39 +++- .../compatible-cli-json-file-service.ts | 212 ++++++++++++++++++ .../services/droid-dashboard-service.ts | 107 +++------ .../droid-dashboard-service.test.ts | 39 ++++ .../raw-json-settings-editor-panel.tsx | 103 +++++++++ ui/src/hooks/use-droid.ts | 48 +++- ui/src/pages/droid.tsx | 142 ++++++------ 7 files changed, 542 insertions(+), 148 deletions(-) create mode 100644 src/web-server/services/compatible-cli-json-file-service.ts create mode 100644 ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx diff --git a/src/web-server/routes/droid-routes.ts b/src/web-server/routes/droid-routes.ts index 0bbb19e5..8cc81950 100644 --- a/src/web-server/routes/droid-routes.ts +++ b/src/web-server/routes/droid-routes.ts @@ -1,8 +1,11 @@ import type { Request, Response } from 'express'; import { Router } from 'express'; import { + DroidRawSettingsConflictError, + DroidRawSettingsValidationError, getDroidDashboardDiagnostics, getDroidRawSettings, + saveDroidRawSettings, } from '../services/droid-dashboard-service'; const router = Router(); @@ -21,7 +24,7 @@ router.get('/diagnostics', (_req: Request, res: Response): void => { /** * GET /api/droid/settings/raw - * Raw ~/.factory/settings.json payload for read-only viewer. + * Raw ~/.factory/settings.json payload for editor. */ router.get('/settings/raw', (_req: Request, res: Response): void => { try { @@ -31,4 +34,38 @@ router.get('/settings/raw', (_req: Request, res: Response): void => { } }); +/** + * PUT /api/droid/settings/raw + * Save raw ~/.factory/settings.json payload from dashboard editor. + */ +router.put('/settings/raw', (req: Request, res: Response): void => { + try { + const { rawText, expectedMtime } = req.body ?? {}; + + if (typeof rawText !== 'string') { + res.status(400).json({ error: 'rawText must be a string.' }); + return; + } + if ( + expectedMtime !== undefined && + (typeof expectedMtime !== 'number' || !Number.isFinite(expectedMtime)) + ) { + res.status(400).json({ error: 'expectedMtime must be a finite number when provided.' }); + return; + } + + res.json(saveDroidRawSettings({ rawText, expectedMtime })); + } catch (error) { + if (error instanceof DroidRawSettingsValidationError) { + res.status(400).json({ error: error.message }); + return; + } + if (error instanceof DroidRawSettingsConflictError) { + res.status(409).json({ error: error.message, mtime: error.mtime }); + return; + } + res.status(500).json({ error: (error as Error).message }); + } +}); + export default router; diff --git a/src/web-server/services/compatible-cli-json-file-service.ts b/src/web-server/services/compatible-cli-json-file-service.ts new file mode 100644 index 00000000..fe425cf8 --- /dev/null +++ b/src/web-server/services/compatible-cli-json-file-service.ts @@ -0,0 +1,212 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface JsonFileDiagnostics { + label: string; + path: string; + resolvedPath: string; + exists: boolean; + isSymlink: boolean; + isRegularFile: boolean; + sizeBytes: number | null; + mtimeMs: number | null; + parseError: string | null; + readError: string | null; +} + +export interface JsonFileProbe { + diagnostics: JsonFileDiagnostics; + json: Record | null; + rawText: string; +} + +interface WriteJsonObjectFileInput { + filePath: string; + rawText: string; + expectedMtime?: number; + fileLabel?: string; + dirMode?: number; + fileMode?: number; +} + +interface WriteJsonObjectFileResult { + mtime: number; +} + +export class JsonFileValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'JsonFileValidationError'; + } +} + +export class JsonFileConflictError extends Error { + readonly code = 'CONFLICT'; + readonly mtime: number; + + constructor(message: string, mtime: number) { + super(message); + this.name = 'JsonFileConflictError'; + this.mtime = mtime; + } +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function probeJsonObjectFile( + filePath: string, + label: string, + displayPath: string +): JsonFileProbe { + if (!fs.existsSync(filePath)) { + return { + diagnostics: { + label, + path: displayPath, + resolvedPath: filePath, + exists: false, + isSymlink: false, + isRegularFile: false, + sizeBytes: null, + mtimeMs: null, + parseError: null, + readError: null, + }, + json: null, + rawText: '{}', + }; + } + + const stat = fs.lstatSync(filePath); + const diagnostics: JsonFileDiagnostics = { + label, + path: displayPath, + resolvedPath: filePath, + exists: true, + isSymlink: stat.isSymbolicLink(), + isRegularFile: stat.isFile(), + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + parseError: null, + readError: null, + }; + + if (diagnostics.isSymlink) { + diagnostics.readError = 'Refusing symlink file for safety.'; + return { diagnostics, json: null, rawText: '{}' }; + } + + if (!diagnostics.isRegularFile) { + diagnostics.readError = 'Target is not a regular file.'; + return { diagnostics, json: null, rawText: '{}' }; + } + + try { + const rawText = fs.readFileSync(filePath, 'utf8'); + try { + const parsed = JSON.parse(rawText); + if (!isObject(parsed)) { + diagnostics.parseError = 'JSON root must be an object.'; + return { diagnostics, json: null, rawText }; + } + return { diagnostics, json: parsed, rawText }; + } catch (error) { + diagnostics.parseError = (error as Error).message; + return { diagnostics, json: null, rawText }; + } + } catch (error) { + diagnostics.readError = (error as Error).message; + return { diagnostics, json: null, rawText: '{}' }; + } +} + +export function parseJsonObjectText( + rawText: string, + fieldName = 'rawText' +): Record { + let parsed: unknown; + try { + parsed = JSON.parse(rawText); + } catch (error) { + throw new JsonFileValidationError(`Invalid JSON in ${fieldName}: ${(error as Error).message}`); + } + + if (!isObject(parsed)) { + throw new JsonFileValidationError(`${fieldName} JSON root must be an object.`); + } + + return parsed; +} + +export function writeJsonObjectFileAtomic( + input: WriteJsonObjectFileInput +): WriteJsonObjectFileResult { + const fileLabel = input.fileLabel || path.basename(input.filePath); + const parsed = parseJsonObjectText(input.rawText, fileLabel); + const targetPath = input.filePath; + const targetDir = path.dirname(targetPath); + const tempPath = targetPath + '.tmp'; + const dirMode = input.dirMode ?? 0o700; + const fileMode = input.fileMode ?? 0o600; + + fs.mkdirSync(targetDir, { recursive: true, mode: dirMode }); + + if (fs.existsSync(targetPath)) { + const stat = fs.lstatSync(targetPath); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing to write: ${fileLabel} is a symlink.`); + } + if (!stat.isFile()) { + throw new Error(`Refusing to write: ${fileLabel} is not a regular file.`); + } + + if (typeof input.expectedMtime !== 'number' || !Number.isFinite(input.expectedMtime)) { + throw new JsonFileConflictError('File metadata not loaded. Refresh and retry.', stat.mtimeMs); + } + if (Math.abs(stat.mtimeMs - input.expectedMtime) > 1000) { + throw new JsonFileConflictError('File modified externally.', stat.mtimeMs); + } + } + + let wroteTemp = false; + try { + if (fs.existsSync(tempPath)) { + const tempStat = fs.lstatSync(tempPath); + if (tempStat.isSymbolicLink()) { + throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`); + } + if (!tempStat.isFile()) { + throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`); + } + } + + fs.writeFileSync(tempPath, JSON.stringify(parsed, null, 2) + '\n', { mode: fileMode }); + wroteTemp = true; + + const tempStat = fs.lstatSync(tempPath); + if (tempStat.isSymbolicLink()) { + throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`); + } + if (!tempStat.isFile()) { + throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`); + } + + fs.renameSync(tempPath, targetPath); + wroteTemp = false; + + try { + fs.chmodSync(targetPath, fileMode); + } catch { + // Best-effort permission hardening. + } + + const stat = fs.statSync(targetPath); + return { mtime: stat.mtimeMs }; + } finally { + if (wroteTemp && fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + } +} diff --git a/src/web-server/services/droid-dashboard-service.ts b/src/web-server/services/droid-dashboard-service.ts index d342c875..8a0e24a1 100644 --- a/src/web-server/services/droid-dashboard-service.ts +++ b/src/web-server/services/droid-dashboard-service.ts @@ -1,15 +1,19 @@ -import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { execFileSync } from 'child_process'; import { detectDroidCli } from '../../targets/droid-detector'; import type { DroidByokDiagnostics, - DroidConfigFileDiagnostics, DroidCustomModelDiagnostics, DroidDashboardDiagnostics, DroidRawSettingsResponse, } from './compatible-cli-types'; +import { + JsonFileConflictError, + JsonFileValidationError, + probeJsonObjectFile, + writeJsonObjectFileAtomic, +} from './compatible-cli-json-file-service'; interface DroidConfigPaths { settingsPath: string; @@ -18,12 +22,21 @@ interface DroidConfigPaths { legacyConfigDisplayPath: string; } -interface JsonFileProbe { - diagnostics: DroidConfigFileDiagnostics; - json: Record | null; +interface SaveDroidRawSettingsInput { rawText: string; + expectedMtime?: number; } +interface SaveDroidRawSettingsResult { + success: true; + mtime: number; +} + +export { + JsonFileConflictError as DroidRawSettingsConflictError, + JsonFileValidationError as DroidRawSettingsValidationError, +}; + function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -87,69 +100,6 @@ function getBinaryVersion(binaryPath: string): string | null { } } -function readJsonFileProbe(filePath: string, label: string, displayPath: string): JsonFileProbe { - if (!fs.existsSync(filePath)) { - return { - diagnostics: { - label, - path: displayPath, - resolvedPath: filePath, - exists: false, - isSymlink: false, - isRegularFile: false, - sizeBytes: null, - mtimeMs: null, - parseError: null, - readError: null, - }, - json: null, - rawText: '{}', - }; - } - - const stat = fs.lstatSync(filePath); - const diagnostics: DroidConfigFileDiagnostics = { - label, - path: displayPath, - resolvedPath: filePath, - exists: true, - isSymlink: stat.isSymbolicLink(), - isRegularFile: stat.isFile(), - sizeBytes: stat.size, - mtimeMs: stat.mtimeMs, - parseError: null, - readError: null, - }; - - if (diagnostics.isSymlink) { - diagnostics.readError = 'Refusing symlink file for safety.'; - return { diagnostics, json: null, rawText: '{}' }; - } - - if (!diagnostics.isRegularFile) { - diagnostics.readError = 'Target is not a regular file.'; - return { diagnostics, json: null, rawText: '{}' }; - } - - try { - const rawText = fs.readFileSync(filePath, 'utf8'); - try { - const parsed = JSON.parse(rawText); - if (!isObject(parsed)) { - diagnostics.parseError = 'JSON root must be an object.'; - return { diagnostics, json: null, rawText }; - } - return { diagnostics, json: parsed, rawText }; - } catch (error) { - diagnostics.parseError = (error as Error).message; - return { diagnostics, json: null, rawText }; - } - } catch (error) { - diagnostics.readError = (error as Error).message; - return { diagnostics, json: null, rawText: '{}' }; - } -} - export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByokDiagnostics { const rows: DroidCustomModelDiagnostics[] = []; const providerBreakdown: Record = {}; @@ -213,12 +163,12 @@ export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { const source = process.env.CCS_DROID_PATH ? 'CCS_DROID_PATH' : binaryPath ? 'PATH' : 'missing'; - const settingsProbe = readJsonFileProbe( + const settingsProbe = probeJsonObjectFile( paths.settingsPath, 'BYOK settings', paths.settingsDisplayPath ); - const legacyConfigProbe = readJsonFileProbe( + const legacyConfigProbe = probeJsonObjectFile( paths.legacyConfigPath, 'Legacy config', paths.legacyConfigDisplayPath @@ -274,7 +224,7 @@ export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { export function getDroidRawSettings(): DroidRawSettingsResponse { const paths = resolveDroidConfigPaths(); - const settingsProbe = readJsonFileProbe( + const settingsProbe = probeJsonObjectFile( paths.settingsPath, 'BYOK settings', paths.settingsDisplayPath @@ -290,3 +240,18 @@ export function getDroidRawSettings(): DroidRawSettingsResponse { parseError: settingsProbe.diagnostics.parseError, }; } + +export function saveDroidRawSettings(input: SaveDroidRawSettingsInput): SaveDroidRawSettingsResult { + const paths = resolveDroidConfigPaths(); + if (typeof input.rawText !== 'string') { + throw new JsonFileValidationError('rawText must be a string.'); + } + + const saved = writeJsonObjectFileAtomic({ + filePath: paths.settingsPath, + rawText: input.rawText, + expectedMtime: input.expectedMtime, + fileLabel: 'settings.json', + }); + return { success: true, mtime: saved.mtime }; +} diff --git a/tests/unit/web-server/droid-dashboard-service.test.ts b/tests/unit/web-server/droid-dashboard-service.test.ts index f1f388a7..fcf2542a 100644 --- a/tests/unit/web-server/droid-dashboard-service.test.ts +++ b/tests/unit/web-server/droid-dashboard-service.test.ts @@ -3,9 +3,12 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { + DroidRawSettingsConflictError, + DroidRawSettingsValidationError, getDroidRawSettings, maskApiKeyPreview, resolveDroidConfigPaths, + saveDroidRawSettings, summarizeDroidCustomModels, } from '../../../src/web-server/services/droid-dashboard-service'; @@ -106,4 +109,40 @@ describe('droid-dashboard-service', () => { expect(raw.settings).toBeNull(); expect(raw.rawText).toContain('invalid-json'); }); + + it('saves valid raw settings content', () => { + const result = saveDroidRawSettings({ + rawText: JSON.stringify({ + model: 'custom:test-model', + customModels: [], + }), + }); + + const settingsPath = path.join(testRoot, '.factory', 'settings.json'); + const written = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + + expect(result.success).toBe(true); + expect(result.mtime).toBeGreaterThan(0); + expect(written.model).toBe('custom:test-model'); + }); + + it('rejects invalid JSON while saving raw settings', () => { + expect(() => saveDroidRawSettings({ rawText: '{ invalid-json' })).toThrow( + DroidRawSettingsValidationError + ); + }); + + it('rejects stale writes with conflict error', () => { + const settingsDir = path.join(testRoot, '.factory'); + fs.mkdirSync(settingsDir, { recursive: true }); + const settingsPath = path.join(settingsDir, 'settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ customModels: [] })); + + expect(() => + saveDroidRawSettings({ + rawText: JSON.stringify({ model: 'custom:next', customModels: [] }), + expectedMtime: 1, + }) + ).toThrow(DroidRawSettingsConflictError); + }); }); diff --git a/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx b/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx new file mode 100644 index 00000000..3b0cf8bf --- /dev/null +++ b/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx @@ -0,0 +1,103 @@ +import { useState } from 'react'; +import { toast } from 'sonner'; +import { Copy, FileCode2, Loader2, RefreshCw, Save } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { CodeEditor } from '@/components/shared/code-editor'; +import { cn } from '@/lib/utils'; + +interface RawJsonSettingsEditorPanelProps { + title: string; + pathLabel: string; + loading: boolean; + parseWarning: string | null | undefined; + value: string; + dirty: boolean; + saving: boolean; + saveDisabled: boolean; + onChange: (nextValue: string) => void; + onSave: () => Promise | void; + onRefresh: () => Promise | void; +} + +export function RawJsonSettingsEditorPanel({ + title, + pathLabel, + loading, + parseWarning, + value, + dirty, + saving, + saveDisabled, + onChange, + onSave, + onRefresh, +}: RawJsonSettingsEditorPanelProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + if (!value) return; + await navigator.clipboard.writeText(value); + setCopied(true); + toast.success('Settings copied to clipboard'); + window.setTimeout(() => setCopied(false), 1500); + }; + + return ( +
+
+
+

+ + {title} + {dirty && ( + + Unsaved + + )} +

+

{pathLabel}

+
+
+ + + +
+
+ +
+ {loading ? ( +
+ + Loading settings.json... +
+ ) : ( +
+ {parseWarning && ( +
+ Parse warning: {parseWarning} +
+ )} +
+
+ +
+
+
+ )} +
+
+ ); +} diff --git a/ui/src/hooks/use-droid.ts b/ui/src/hooks/use-droid.ts index 2a647f6c..a4b2c062 100644 --- a/ui/src/hooks/use-droid.ts +++ b/ui/src/hooks/use-droid.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { withApiBase } from '@/lib/api-client'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ApiConflictError, withApiBase } from '@/lib/api-client'; export interface DroidBinaryDiagnostics { installed: boolean; @@ -69,6 +69,16 @@ export interface DroidRawSettings { parseError: string | null; } +interface SaveDroidRawSettingsInput { + rawText: string; + expectedMtime?: number; +} + +interface SaveDroidRawSettingsResponse { + success: true; + mtime: number; +} + async function fetchDroidDiagnostics(): Promise { const res = await fetch(withApiBase('/droid/diagnostics')); if (!res.ok) throw new Error('Failed to fetch Droid diagnostics'); @@ -81,7 +91,26 @@ async function fetchDroidRawSettings(): Promise { return res.json(); } +async function saveDroidRawSettings( + data: SaveDroidRawSettingsInput +): Promise { + const res = await fetch(withApiBase('/droid/settings/raw'), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (res.status === 409) throw new ApiConflictError('Droid raw settings changed externally'); + + if (!res.ok) { + const payload = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(payload?.error || 'Failed to save Droid raw settings'); + } + return res.json(); +} + export function useDroid() { + const queryClient = useQueryClient(); + const diagnosticsQuery = useQuery({ queryKey: ['droid-diagnostics'], queryFn: fetchDroidDiagnostics, @@ -93,6 +122,14 @@ export function useDroid() { queryFn: fetchDroidRawSettings, }); + const saveRawSettingsMutation = useMutation({ + mutationFn: saveDroidRawSettings, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['droid-diagnostics'] }); + queryClient.invalidateQueries({ queryKey: ['droid-raw-settings'] }); + }, + }); + return useMemo( () => ({ diagnostics: diagnosticsQuery.data, @@ -104,6 +141,10 @@ export function useDroid() { rawSettingsLoading: rawSettingsQuery.isLoading, rawSettingsError: rawSettingsQuery.error, refetchRawSettings: rawSettingsQuery.refetch, + + saveRawSettings: saveRawSettingsMutation.mutate, + saveRawSettingsAsync: saveRawSettingsMutation.mutateAsync, + isSavingRawSettings: saveRawSettingsMutation.isPending, }), [ diagnosticsQuery.data, @@ -114,6 +155,9 @@ export function useDroid() { rawSettingsQuery.isLoading, rawSettingsQuery.error, rawSettingsQuery.refetch, + saveRawSettingsMutation.mutate, + saveRawSettingsMutation.mutateAsync, + saveRawSettingsMutation.isPending, ] ); } diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx index 3f111297..d53256cb 100644 --- a/ui/src/pages/droid.tsx +++ b/ui/src/pages/droid.tsx @@ -4,24 +4,21 @@ import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { AlertTriangle, CheckCircle2, - Copy, - FileCode2, Folder, GripVertical, Loader2, - RefreshCw, Server, ShieldCheck, TerminalSquare, XCircle, } from 'lucide-react'; import { useDroid } from '@/hooks/use-droid'; -import { Button } from '@/components/ui/button'; +import { isApiConflictError } from '@/lib/api-client'; +import { RawJsonSettingsEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel'; 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 { CodeEditor } from '@/components/shared/code-editor'; import { cn } from '@/lib/utils'; function formatTimestamp(value: number | null | undefined): string { @@ -36,6 +33,18 @@ function formatBytes(value: number | null | undefined): string { return `${(value / (1024 * 1024)).toFixed(2)} MB`; } +function validateJsonObject(text: string): { valid: true } | { 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 }; + } catch (error) { + return { valid: false, error: (error as Error).message }; + } +} + function DetailRow({ label, value, @@ -62,27 +71,48 @@ export function DroidPage() { rawSettings, rawSettingsLoading, refetchRawSettings, + saveRawSettingsAsync, + isSavingRawSettings, } = useDroid(); - const [copied, setCopied] = useState(false); - - const copyRawSettings = async () => { - if (!rawSettings?.rawText) return; - await navigator.clipboard.writeText(rawSettings.rawText); - setCopied(true); - toast.success('Droid settings copied to clipboard'); - window.setTimeout(() => setCopied(false), 1500); - }; + const [rawDraftText, setRawDraftText] = useState(null); + const rawBaseText = rawSettings?.rawText ?? '{}'; + const rawEditorText = rawDraftText ?? rawBaseText; + const rawConfigDirty = rawDraftText !== null && rawDraftText !== rawBaseText; const refreshAll = async () => { await Promise.all([refetchDiagnostics(), refetchRawSettings()]); }; + const handleSaveRawSettings = async () => { + if (!rawEditorValidation.valid) { + toast.error(`Invalid JSON: ${rawEditorValidation.error}`); + return; + } + + try { + await saveRawSettingsAsync({ + rawText: rawEditorText, + expectedMtime: rawSettings?.exists ? rawSettings.mtime : undefined, + }); + setRawDraftText(null); + await Promise.all([refetchDiagnostics(), refetchRawSettings()]); + toast.success('Droid settings saved'); + } catch (error) { + if (isApiConflictError(error)) { + toast.error('Droid settings changed externally. Refresh and retry.'); + } else { + toast.error((error as Error).message || 'Failed to save Droid settings'); + } + } + }; + const customModels = diagnostics?.byok.customModels ?? []; const providerRows = useMemo( () => Object.entries(diagnostics?.byok.providerBreakdown ?? {}).sort((a, b) => b[1] - a[1]), [diagnostics?.byok.providerBreakdown] ); + const rawEditorValidation = validateJsonObject(rawEditorText); const renderOverview = () => { if (diagnosticsLoading) { @@ -313,66 +343,30 @@ export function DroidPage() { -
-
-
-

- - Droid BYOK Settings -

-

- {rawSettings?.path || '~/.factory/settings.json'} -

-
-
- - -
-
- -
- {rawSettingsLoading ? ( -
- - Loading settings.json... -
- ) : ( -
- {rawSettings?.parseError && ( -
- Parse warning: {rawSettings.parseError} -
- )} -
-
- {}} - language="json" - readonly - minHeight="100%" - /> -
-
-
- )} -
-
+ { + if (next === rawBaseText) { + setRawDraftText(null); + return; + } + setRawDraftText(next); + }} + onSave={handleSaveRawSettings} + onRefresh={refreshAll} + />
From 20e48b3dc0e91fd0984ff1d059169aa6b64a84ce Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 23:42:17 +0700 Subject: [PATCH 4/7] refactor(dashboard): switch droid settings I/O to async fs - replace sync fs calls in compatible CLI JSON helper with fs.promises - make droid diagnostics/raw-settings service + routes async - update unit tests for async save/read paths --- src/web-server/routes/droid-routes.ts | 12 ++-- .../compatible-cli-json-file-service.ts | 59 ++++++++++++------- .../services/droid-dashboard-service.ts | 16 ++--- .../droid-dashboard-service.test.ts | 22 +++---- 4 files changed, 65 insertions(+), 44 deletions(-) diff --git a/src/web-server/routes/droid-routes.ts b/src/web-server/routes/droid-routes.ts index 8cc81950..f18867d6 100644 --- a/src/web-server/routes/droid-routes.ts +++ b/src/web-server/routes/droid-routes.ts @@ -14,9 +14,9 @@ const router = Router(); * GET /api/droid/diagnostics * Dashboard-ready Droid installation + BYOK configuration diagnostics. */ -router.get('/diagnostics', (_req: Request, res: Response): void => { +router.get('/diagnostics', async (_req: Request, res: Response): Promise => { try { - res.json(getDroidDashboardDiagnostics()); + res.json(await getDroidDashboardDiagnostics()); } catch (error) { res.status(500).json({ error: (error as Error).message }); } @@ -26,9 +26,9 @@ router.get('/diagnostics', (_req: Request, res: Response): void => { * GET /api/droid/settings/raw * Raw ~/.factory/settings.json payload for editor. */ -router.get('/settings/raw', (_req: Request, res: Response): void => { +router.get('/settings/raw', async (_req: Request, res: Response): Promise => { try { - res.json(getDroidRawSettings()); + res.json(await getDroidRawSettings()); } catch (error) { res.status(500).json({ error: (error as Error).message }); } @@ -38,7 +38,7 @@ router.get('/settings/raw', (_req: Request, res: Response): void => { * PUT /api/droid/settings/raw * Save raw ~/.factory/settings.json payload from dashboard editor. */ -router.put('/settings/raw', (req: Request, res: Response): void => { +router.put('/settings/raw', async (req: Request, res: Response): Promise => { try { const { rawText, expectedMtime } = req.body ?? {}; @@ -54,7 +54,7 @@ router.put('/settings/raw', (req: Request, res: Response): void => { return; } - res.json(saveDroidRawSettings({ rawText, expectedMtime })); + res.json(await saveDroidRawSettings({ rawText, expectedMtime })); } catch (error) { if (error instanceof DroidRawSettingsValidationError) { res.status(400).json({ error: error.message }); diff --git a/src/web-server/services/compatible-cli-json-file-service.ts b/src/web-server/services/compatible-cli-json-file-service.ts index fe425cf8..eacf3a65 100644 --- a/src/web-server/services/compatible-cli-json-file-service.ts +++ b/src/web-server/services/compatible-cli-json-file-service.ts @@ -1,4 +1,4 @@ -import * as fs from 'fs'; +import { promises as fs } from 'fs'; import * as path from 'path'; export interface JsonFileDiagnostics { @@ -55,12 +55,24 @@ function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -export function probeJsonObjectFile( +async function statPath(filePath: string): Promise { + try { + return await fs.lstat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw error; + } +} + +export async function probeJsonObjectFile( filePath: string, label: string, displayPath: string -): JsonFileProbe { - if (!fs.existsSync(filePath)) { +): Promise { + const stat = await statPath(filePath); + if (!stat) { return { diagnostics: { label, @@ -79,7 +91,6 @@ export function probeJsonObjectFile( }; } - const stat = fs.lstatSync(filePath); const diagnostics: JsonFileDiagnostics = { label, path: displayPath, @@ -104,7 +115,7 @@ export function probeJsonObjectFile( } try { - const rawText = fs.readFileSync(filePath, 'utf8'); + const rawText = await fs.readFile(filePath, 'utf8'); try { const parsed = JSON.parse(rawText); if (!isObject(parsed)) { @@ -140,9 +151,9 @@ export function parseJsonObjectText( return parsed; } -export function writeJsonObjectFileAtomic( +export async function writeJsonObjectFileAtomic( input: WriteJsonObjectFileInput -): WriteJsonObjectFileResult { +): Promise { const fileLabel = input.fileLabel || path.basename(input.filePath); const parsed = parseJsonObjectText(input.rawText, fileLabel); const targetPath = input.filePath; @@ -151,10 +162,11 @@ export function writeJsonObjectFileAtomic( const dirMode = input.dirMode ?? 0o700; const fileMode = input.fileMode ?? 0o600; - fs.mkdirSync(targetDir, { recursive: true, mode: dirMode }); + await fs.mkdir(targetDir, { recursive: true, mode: dirMode }); - if (fs.existsSync(targetPath)) { - const stat = fs.lstatSync(targetPath); + const targetStat = await statPath(targetPath); + if (targetStat) { + const stat = targetStat; if (stat.isSymbolicLink()) { throw new Error(`Refusing to write: ${fileLabel} is a symlink.`); } @@ -172,8 +184,9 @@ export function writeJsonObjectFileAtomic( let wroteTemp = false; try { - if (fs.existsSync(tempPath)) { - const tempStat = fs.lstatSync(tempPath); + const existingTempStat = await statPath(tempPath); + if (existingTempStat) { + const tempStat = existingTempStat; if (tempStat.isSymbolicLink()) { throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`); } @@ -182,10 +195,10 @@ export function writeJsonObjectFileAtomic( } } - fs.writeFileSync(tempPath, JSON.stringify(parsed, null, 2) + '\n', { mode: fileMode }); + await fs.writeFile(tempPath, JSON.stringify(parsed, null, 2) + '\n', { mode: fileMode }); wroteTemp = true; - const tempStat = fs.lstatSync(tempPath); + const tempStat = await fs.lstat(tempPath); if (tempStat.isSymbolicLink()) { throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`); } @@ -193,20 +206,26 @@ export function writeJsonObjectFileAtomic( throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`); } - fs.renameSync(tempPath, targetPath); + await fs.rename(tempPath, targetPath); wroteTemp = false; try { - fs.chmodSync(targetPath, fileMode); + await fs.chmod(targetPath, fileMode); } catch { // Best-effort permission hardening. } - const stat = fs.statSync(targetPath); + const stat = await fs.stat(targetPath); return { mtime: stat.mtimeMs }; } finally { - if (wroteTemp && fs.existsSync(tempPath)) { - fs.unlinkSync(tempPath); + if (wroteTemp) { + try { + await fs.unlink(tempPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } } } } diff --git a/src/web-server/services/droid-dashboard-service.ts b/src/web-server/services/droid-dashboard-service.ts index 8a0e24a1..903c56ab 100644 --- a/src/web-server/services/droid-dashboard-service.ts +++ b/src/web-server/services/droid-dashboard-service.ts @@ -157,18 +157,18 @@ export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByo }; } -export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { +export async function getDroidDashboardDiagnostics(): Promise { const paths = resolveDroidConfigPaths(); const binaryPath = detectDroidCli(); const source = process.env.CCS_DROID_PATH ? 'CCS_DROID_PATH' : binaryPath ? 'PATH' : 'missing'; - const settingsProbe = probeJsonObjectFile( + const settingsProbe = await probeJsonObjectFile( paths.settingsPath, 'BYOK settings', paths.settingsDisplayPath ); - const legacyConfigProbe = probeJsonObjectFile( + const legacyConfigProbe = await probeJsonObjectFile( paths.legacyConfigPath, 'Legacy config', paths.legacyConfigDisplayPath @@ -222,9 +222,9 @@ export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { }; } -export function getDroidRawSettings(): DroidRawSettingsResponse { +export async function getDroidRawSettings(): Promise { const paths = resolveDroidConfigPaths(); - const settingsProbe = probeJsonObjectFile( + const settingsProbe = await probeJsonObjectFile( paths.settingsPath, 'BYOK settings', paths.settingsDisplayPath @@ -241,13 +241,15 @@ export function getDroidRawSettings(): DroidRawSettingsResponse { }; } -export function saveDroidRawSettings(input: SaveDroidRawSettingsInput): SaveDroidRawSettingsResult { +export async function saveDroidRawSettings( + input: SaveDroidRawSettingsInput +): Promise { const paths = resolveDroidConfigPaths(); if (typeof input.rawText !== 'string') { throw new JsonFileValidationError('rawText must be a string.'); } - const saved = writeJsonObjectFileAtomic({ + const saved = await writeJsonObjectFileAtomic({ filePath: paths.settingsPath, rawText: input.rawText, expectedMtime: input.expectedMtime, diff --git a/tests/unit/web-server/droid-dashboard-service.test.ts b/tests/unit/web-server/droid-dashboard-service.test.ts index fcf2542a..c781785f 100644 --- a/tests/unit/web-server/droid-dashboard-service.test.ts +++ b/tests/unit/web-server/droid-dashboard-service.test.ts @@ -88,8 +88,8 @@ describe('droid-dashboard-service', () => { expect(summary.customModels[0].apiKeyPreview).toBe('***1234'); }); - it('returns raw settings payload for missing settings file', () => { - const raw = getDroidRawSettings(); + it('returns raw settings payload for missing settings file', async () => { + const raw = await getDroidRawSettings(); expect(raw.exists).toBe(false); expect(raw.path).toBe('~/.factory/settings.json'); @@ -97,12 +97,12 @@ describe('droid-dashboard-service', () => { expect(raw.settings).toBeNull(); }); - it('returns parseError when settings.json is invalid JSON', () => { + it('returns parseError when settings.json is invalid JSON', async () => { const settingsDir = path.join(testRoot, '.factory'); fs.mkdirSync(settingsDir, { recursive: true }); fs.writeFileSync(path.join(settingsDir, 'settings.json'), '{ invalid-json'); - const raw = getDroidRawSettings(); + const raw = await getDroidRawSettings(); expect(raw.exists).toBe(true); expect(raw.parseError).toBeString(); @@ -110,8 +110,8 @@ describe('droid-dashboard-service', () => { expect(raw.rawText).toContain('invalid-json'); }); - it('saves valid raw settings content', () => { - const result = saveDroidRawSettings({ + it('saves valid raw settings content', async () => { + const result = await saveDroidRawSettings({ rawText: JSON.stringify({ model: 'custom:test-model', customModels: [], @@ -126,23 +126,23 @@ describe('droid-dashboard-service', () => { expect(written.model).toBe('custom:test-model'); }); - it('rejects invalid JSON while saving raw settings', () => { - expect(() => saveDroidRawSettings({ rawText: '{ invalid-json' })).toThrow( + it('rejects invalid JSON while saving raw settings', async () => { + await expect(saveDroidRawSettings({ rawText: '{ invalid-json' })).rejects.toThrow( DroidRawSettingsValidationError ); }); - it('rejects stale writes with conflict error', () => { + it('rejects stale writes with conflict error', async () => { const settingsDir = path.join(testRoot, '.factory'); fs.mkdirSync(settingsDir, { recursive: true }); const settingsPath = path.join(settingsDir, 'settings.json'); fs.writeFileSync(settingsPath, JSON.stringify({ customModels: [] })); - expect(() => + await expect( saveDroidRawSettings({ rawText: JSON.stringify({ model: 'custom:next', customModels: [] }), expectedMtime: 1, }) - ).toThrow(DroidRawSettingsConflictError); + ).rejects.toThrow(DroidRawSettingsConflictError); }); }); From 031dcd99c3978ca6d00a402a2cb2ce5e9d2306a2 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Feb 2026 00:03:58 +0700 Subject: [PATCH 5/7] 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) --- .../services/compatible-cli-docs-registry.ts | 105 +++++++++ .../services/compatible-cli-types.ts | 30 ++- .../services/droid-dashboard-service.ts | 18 +- .../droid-dashboard-service.test.ts | 14 ++ .../droid-settings-quick-controls-card.tsx | 214 ++++++++++++++++++ ui/src/hooks/use-droid.ts | 18 ++ ui/src/pages/droid.tsx | 148 +++++++++++- 7 files changed, 519 insertions(+), 28 deletions(-) create mode 100644 src/web-server/services/compatible-cli-docs-registry.ts create mode 100644 ui/src/components/compatible-cli/droid-settings-quick-controls-card.tsx diff --git a/src/web-server/services/compatible-cli-docs-registry.ts b/src/web-server/services/compatible-cli-docs-registry.ts new file mode 100644 index 00000000..551fa978 --- /dev/null +++ b/src/web-server/services/compatible-cli-docs-registry.ts @@ -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 = { + 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:)', + '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; +} diff --git a/src/web-server/services/compatible-cli-types.ts b/src/web-server/services/compatible-cli-types.ts index bb9898e2..6d93c1ce 100644 --- a/src/web-server/services/compatible-cli-types.ts +++ b/src/web-server/services/compatible-cli-types.ts @@ -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 { diff --git a/src/web-server/services/droid-dashboard-service.ts b/src/web-server/services/droid-dashboard-service.ts index 903c56ab..ad960f21 100644 --- a/src/web-server/services/droid-dashboard-service.ts +++ b/src/web-server/services/droid-dashboard-service.ts @@ -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 { 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)', - 'droid exec supports --model for one-off execution mode', - ], - }, + docsReference, }; } diff --git a/tests/unit/web-server/droid-dashboard-service.test.ts b/tests/unit/web-server/droid-dashboard-service.test.ts index c781785f..50ebbe2a 100644 --- a/tests/unit/web-server/droid-dashboard-service.test.ts +++ b/tests/unit/web-server/droid-dashboard-service.test.ts @@ -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({ diff --git a/ui/src/components/compatible-cli/droid-settings-quick-controls-card.tsx b/ui/src/components/compatible-cli/droid-settings-quick-controls-card.tsx new file mode 100644 index 00000000..80f43ec7 --- /dev/null +++ b/ui/src/components/compatible-cli/droid-settings-quick-controls-card.tsx @@ -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 ( + + + + + Quick Settings + + settings.json + + + + + {disabledReason &&

{disabledReason}

} + +
+ {enumFieldConfig.map((field) => ( +
+

{field.label}

+ +

{field.description}

+
+ ))} + + {numberFieldConfig.map((field) => ( +
+

{field.label}

+ { + 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} + /> +
+ ))} + + {booleanFieldConfig.map((field) => ( +
+

{field.label}

+ +
+ ))} +
+
+
+ ); +} diff --git a/ui/src/hooks/use-droid.ts b/ui/src/hooks/use-droid.ts index a4b2c062..7339829e 100644 --- a/ui/src/hooks/use-droid.ts +++ b/ui/src/hooks/use-droid.ts @@ -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[]; }; } diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx index d53256cb..796eb075 100644 --- a/ui/src/pages/droid.tsx +++ b/ui/src/pages/droid.tsx @@ -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 } | { 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 }; } 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() { + { + updateSettingsField(key, value); + }} + onBooleanSettingChange={(key, value) => { + updateSettingsField(key, value); + }} + onNumberSettingChange={(key, value) => { + updateSettingsField(key, value); + }} + /> + @@ -255,6 +343,54 @@ export function DroidPage() {

))} +
+

+ Factory Docs +

+
+ {diagnostics.docsReference.links.map((link) => ( + +
+ {link.label} + +
+

{link.description}

+
+ ))} +
+
+ +
+

+ Provider Fact-Check Docs +

+
+ {diagnostics.docsReference.providerDocs.map((providerDoc) => ( + +
+ {providerDoc.label} + +
+

+ provider: {providerDoc.provider} | format: {providerDoc.apiFormat} +

+
+ ))} +
+
+

Provider values: {diagnostics.docsReference.providerValues.join(', ')}

@@ -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} From e7f3b3ffcf4d020ac20561e1d2c24d7aaa33e494 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Feb 2026 00:09:15 +0700 Subject: [PATCH 6/7] fix(dashboard): guard droid docs links for older API payloads - prevent blank page when docsReference.links/providerDocs are absent - fallback to empty lists so /droid stays usable during schema drift --- ui/src/pages/droid.tsx | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx index 796eb075..43867f39 100644 --- a/ui/src/pages/droid.tsx +++ b/ui/src/pages/droid.tsx @@ -203,6 +203,19 @@ export function DroidPage() { ); } + const docsReference = diagnostics.docsReference ?? { + notes: [], + links: [], + providerDocs: [], + providerValues: [], + settingsHierarchy: [], + }; + const docsNotes = docsReference.notes ?? []; + const docsLinks = docsReference.links ?? []; + const providerDocs = docsReference.providerDocs ?? []; + const providerValues = docsReference.providerValues ?? []; + const settingsHierarchy = docsReference.settingsHierarchy ?? []; + return (
@@ -337,7 +350,7 @@ export function DroidPage() { - {diagnostics.docsReference.notes.map((note) => ( + {docsNotes.map((note) => (

- {note}

@@ -348,7 +361,7 @@ export function DroidPage() { Factory Docs

- {diagnostics.docsReference.links.map((link) => ( + {docsLinks.map((link) => (
- {diagnostics.docsReference.providerDocs.map((providerDoc) => ( + {providerDocs.map((providerDoc) => (

- Provider values: {diagnostics.docsReference.providerValues.join(', ')} + Provider values: {providerValues.join(', ')}

- Settings hierarchy: {diagnostics.docsReference.settingsHierarchy.join(' -> ')} + Settings hierarchy: {settingsHierarchy.join(' -> ')}

From 1704b802ee4f32218d6c5707dd0dc2b617064dde Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Feb 2026 00:14:31 +0700 Subject: [PATCH 7/7] fix(dashboard): make droid docs links always clickable - render URL text as explicit anchors in docs cards - linkify raw URLs in docs notes - fallback to built-in Droid docs links when legacy API payload omits links --- ui/src/pages/droid.tsx | 98 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 6 deletions(-) diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx index 43867f39..5bef6e73 100644 --- a/ui/src/pages/droid.tsx +++ b/ui/src/pages/droid.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { type ReactNode, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { @@ -26,6 +26,83 @@ import { Separator } from '@/components/ui/separator'; import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; +const DEFAULT_DROID_FACTORY_DOC_LINKS = [ + { + id: 'droid-cli-overview', + label: 'Droid CLI Overview', + url: 'https://docs.factory.ai/cli/', + 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/', + 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/', + description: 'Supported settings keys, defaults, and allowed values.', + }, +]; + +const DEFAULT_DROID_PROVIDER_DOC_LINKS = [ + { + 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', + }, +]; + +function renderTextWithLinks(text: string): ReactNode[] { + const urlPattern = /https?:\/\/[^\s)]+/g; + const nodes: ReactNode[] = []; + let cursor = 0; + let match: RegExpExecArray | null; + + while ((match = urlPattern.exec(text)) !== null) { + const [url] = match; + const index = match.index; + + if (index > cursor) { + nodes.push(text.slice(cursor, index)); + } + + nodes.push( +
+ {url} + + ); + cursor = index + url.length; + } + + if (cursor < text.length) { + nodes.push(text.slice(cursor)); + } + + return nodes.length > 0 ? nodes : [text]; +} + function formatTimestamp(value: number | null | undefined): string { if (!value || !Number.isFinite(value)) return 'N/A'; return new Date(value).toLocaleString(); @@ -211,8 +288,11 @@ export function DroidPage() { settingsHierarchy: [], }; const docsNotes = docsReference.notes ?? []; - const docsLinks = docsReference.links ?? []; - const providerDocs = docsReference.providerDocs ?? []; + const docsLinksRaw = docsReference.links ?? []; + const providerDocsRaw = docsReference.providerDocs ?? []; + const docsLinks = docsLinksRaw.length > 0 ? docsLinksRaw : DEFAULT_DROID_FACTORY_DOC_LINKS; + const providerDocs = + providerDocsRaw.length > 0 ? providerDocsRaw : DEFAULT_DROID_PROVIDER_DOC_LINKS; const providerValues = docsReference.providerValues ?? []; const settingsHierarchy = docsReference.settingsHierarchy ?? []; @@ -350,9 +430,9 @@ export function DroidPage() { - {docsNotes.map((note) => ( -

- - {note} + {docsNotes.map((note, index) => ( +

+ - {renderTextWithLinks(note)}

))} @@ -374,6 +454,9 @@ export function DroidPage() {

{link.description}

+

+ {link.url} +

))}
@@ -399,6 +482,9 @@ export function DroidPage() {

provider: {providerDoc.provider} | format: {providerDoc.apiFormat}

+

+ {providerDoc.url} +

))}