From 84755114422bb47d6e57c2e249a71f4583fcbe68 Mon Sep 17 00:00:00 2001 From: mssssss123 <824186479@qq.com> Date: Thu, 23 Jul 2026 12:04:04 +0800 Subject: [PATCH 01/27] feat(update): enhance update process with dynamic bash executable resolution and improved error handling - Introduced functions to resolve the appropriate bash executable based on the environment, improving compatibility across platforms. - Updated the update application logic to utilize the resolved bash executable for executing update scripts. - Enhanced error handling to provide more informative messages when the update process fails, particularly regarding bash executable issues. - Added new service functions for managing update runtime errors and restart commands. - Implemented unit tests to verify the new functionality and ensure robust error handling. --- ui/server/routes/update.js | 58 +- ui/server/services/updateRuntime.js | 98 ++++ ui/server/services/updateRuntime.test.js | 69 +++ ui/src/components/app-shell/AppShellV2.tsx | 2 +- ui/src/components/settingsNew/SettingsNew.tsx | 128 +++++ .../components/settingsNew/navigation.spec.ts | 23 + ui/src/components/settingsNew/navigation.ts | 24 + .../settingsNew/shared/components/Inputs.tsx | 447 +++++++++++++++ .../settingsNew/shared/constants.ts | 23 + .../shared/hooks/useSettingsController.ts | 132 +++++ ui/src/components/settingsNew/shared/types.ts | 35 ++ .../shared/utils/alwaysOnConfigPatch.ts | 39 ++ .../settingsNew/shared/utils/secret.ts | 18 + .../shared/view/PageSectionHeader.tsx | 23 + .../settingsNew/shared/view/SettingsCard.tsx | 26 + .../settingsNew/shared/view/SettingsRow.tsx | 30 + .../shared/view/SettingsSection.tsx | 34 ++ .../shared/view/SettingsToggle.tsx | 41 ++ .../settingsNew/shared/view/index.ts | 5 + ui/src/components/settingsNew/types.ts | 24 + .../settingsNew/view/SettingsNewContent.tsx | 111 ++++ .../settingsNew/view/SettingsNewSidebar.tsx | 135 +++++ .../settingsNew/view/about/index.tsx | 273 +++++++++ .../advanced/components/CustomEnvSection.tsx | 161 ++++++ .../advanced/components/ServiceSection.tsx | 133 +++++ .../settingsNew/view/advanced/index.tsx | 192 +++++++ .../view/advanced/utils/constants.ts | 6 + .../view/agentMemory/MemoryDataSection.tsx | 462 +++++++++++++++ .../settingsNew/view/agentMemory/index.tsx | 404 +++++++++++++ .../agentModel/components/AgentsSection.tsx | 254 +++++++++ .../settingsNew/view/agentModel/index.tsx | 54 ++ .../view/agentModel/types/index.ts | 14 + .../view/agentModel/utils/modelRefs.ts | 120 ++++ .../components/AlwaysOnSection.tsx | 375 ++++++++++++ .../settingsNew/view/agentResident/index.tsx | 63 ++ .../components/ModelPricingEditor.tsx | 158 ++++++ .../agentRoute/components/ModelRefInput.tsx | 24 + .../components/RouterFallbackEditor.tsx | 152 +++++ .../components/RouterLevelEditor.tsx | 104 ++++ .../agentRoute/components/RouterSection.tsx | 437 ++++++++++++++ .../components/TokenSaverRulesEditor.tsx | 92 +++ .../components/TokenSaverTierEditor.tsx | 125 ++++ .../settingsNew/view/agentRoute/index.tsx | 54 ++ .../view/agentRoute/utils/router.ts | 48 ++ .../agentSchedule/components/CronSection.tsx | 66 +++ .../settingsNew/view/agentSchedule/index.tsx | 54 ++ .../view/agentSchedule/utils/cron.ts | 5 + .../agentSearch/components/ToolsSection.tsx | 394 +++++++++++++ .../settingsNew/view/agentSearch/index.tsx | 54 ++ .../agentSearch/utils/webSearchConfig.spec.ts | 59 ++ .../view/agentSearch/utils/webSearchConfig.ts | 32 ++ .../components/AdvancedJsonEditor.tsx | 27 + .../extensions/components/FormEditors.tsx | 175 ++++++ .../components/McpServerFormCard.tsx | 175 ++++++ .../settingsNew/view/extensions/index.tsx | 322 +++++++++++ .../settingsNew/view/extensions/types/mcp.ts | 32 ++ .../view/extensions/utils/constants.ts | 19 + .../view/extensions/utils/mcpServerForm.ts | 105 ++++ .../view/general/ChatInputSection.tsx | 83 +++ .../view/general/CodeEditorSection.tsx | 111 ++++ .../view/general/GeneralSettingsSection.tsx | 152 +++++ .../settingsNew/view/general/index.tsx | 39 ++ .../components/GatewayConfigSection.tsx | 61 ++ .../im/components/FeishuChannelSection.tsx | 474 ++++++++++++++++ .../im/components/WeComChannelSection.tsx | 494 ++++++++++++++++ .../im/components/WeixinChannelSection.tsx | 195 +++++++ .../integrations/im/hooks/useGatewayStatus.ts | 27 + .../view/integrations/im/index.tsx | 38 ++ .../view/integrations/im/types/index.ts | 27 + .../settingsNew/view/integrations/index.tsx | 44 ++ .../modelPool/components/CatalogPicker.tsx | 79 +++ .../modelPool/components/ModelsSection.tsx | 110 ++++ .../modelPool/components/ProviderCard.tsx | 537 ++++++++++++++++++ .../settingsNew/view/modelPool/index.tsx | 59 ++ .../settingsNew/view/modelPool/types/index.ts | 149 +++++ .../view/modelPool/utils/configYaml.ts | 18 + .../settingsNew/view/modelPool/utils/patch.ts | 21 + .../view/modelPool/utils/providerRefs.ts | 141 +++++ .../settingsNew/view/officePreview/index.tsx | 441 ++++++++++++++ .../components/PermissionControlSection.tsx | 117 ++++ .../components/PermissionRulesSection.tsx | 151 +++++ .../privacy/components/TelemetrySection.tsx | 43 ++ .../settingsNew/view/privacy/index.tsx | 271 +++++++++ .../settingsNew/view/privacy/types/index.ts | 19 + .../view/privacy/utils/constants.ts | 33 ++ .../view/privacy/utils/permissions.ts | 107 ++++ .../view/privacy/utils/telemetry.ts | 37 ++ ui/src/i18n/locales/en/settings.json | 93 +++ ui/src/i18n/locales/zh-CN/settings.json | 93 +++ 89 files changed, 10989 insertions(+), 24 deletions(-) create mode 100644 ui/server/services/updateRuntime.js create mode 100644 ui/server/services/updateRuntime.test.js create mode 100644 ui/src/components/settingsNew/SettingsNew.tsx create mode 100644 ui/src/components/settingsNew/navigation.spec.ts create mode 100644 ui/src/components/settingsNew/navigation.ts create mode 100644 ui/src/components/settingsNew/shared/components/Inputs.tsx create mode 100644 ui/src/components/settingsNew/shared/constants.ts create mode 100644 ui/src/components/settingsNew/shared/hooks/useSettingsController.ts create mode 100644 ui/src/components/settingsNew/shared/types.ts create mode 100644 ui/src/components/settingsNew/shared/utils/alwaysOnConfigPatch.ts create mode 100644 ui/src/components/settingsNew/shared/utils/secret.ts create mode 100644 ui/src/components/settingsNew/shared/view/PageSectionHeader.tsx create mode 100644 ui/src/components/settingsNew/shared/view/SettingsCard.tsx create mode 100644 ui/src/components/settingsNew/shared/view/SettingsRow.tsx create mode 100644 ui/src/components/settingsNew/shared/view/SettingsSection.tsx create mode 100644 ui/src/components/settingsNew/shared/view/SettingsToggle.tsx create mode 100644 ui/src/components/settingsNew/shared/view/index.ts create mode 100644 ui/src/components/settingsNew/types.ts create mode 100644 ui/src/components/settingsNew/view/SettingsNewContent.tsx create mode 100644 ui/src/components/settingsNew/view/SettingsNewSidebar.tsx create mode 100644 ui/src/components/settingsNew/view/about/index.tsx create mode 100644 ui/src/components/settingsNew/view/advanced/components/CustomEnvSection.tsx create mode 100644 ui/src/components/settingsNew/view/advanced/components/ServiceSection.tsx create mode 100644 ui/src/components/settingsNew/view/advanced/index.tsx create mode 100644 ui/src/components/settingsNew/view/advanced/utils/constants.ts create mode 100644 ui/src/components/settingsNew/view/agentMemory/MemoryDataSection.tsx create mode 100644 ui/src/components/settingsNew/view/agentMemory/index.tsx create mode 100644 ui/src/components/settingsNew/view/agentModel/components/AgentsSection.tsx create mode 100644 ui/src/components/settingsNew/view/agentModel/index.tsx create mode 100644 ui/src/components/settingsNew/view/agentModel/types/index.ts create mode 100644 ui/src/components/settingsNew/view/agentModel/utils/modelRefs.ts create mode 100644 ui/src/components/settingsNew/view/agentResident/components/AlwaysOnSection.tsx create mode 100644 ui/src/components/settingsNew/view/agentResident/index.tsx create mode 100644 ui/src/components/settingsNew/view/agentRoute/components/ModelPricingEditor.tsx create mode 100644 ui/src/components/settingsNew/view/agentRoute/components/ModelRefInput.tsx create mode 100644 ui/src/components/settingsNew/view/agentRoute/components/RouterFallbackEditor.tsx create mode 100644 ui/src/components/settingsNew/view/agentRoute/components/RouterLevelEditor.tsx create mode 100644 ui/src/components/settingsNew/view/agentRoute/components/RouterSection.tsx create mode 100644 ui/src/components/settingsNew/view/agentRoute/components/TokenSaverRulesEditor.tsx create mode 100644 ui/src/components/settingsNew/view/agentRoute/components/TokenSaverTierEditor.tsx create mode 100644 ui/src/components/settingsNew/view/agentRoute/index.tsx create mode 100644 ui/src/components/settingsNew/view/agentRoute/utils/router.ts create mode 100644 ui/src/components/settingsNew/view/agentSchedule/components/CronSection.tsx create mode 100644 ui/src/components/settingsNew/view/agentSchedule/index.tsx create mode 100644 ui/src/components/settingsNew/view/agentSchedule/utils/cron.ts create mode 100644 ui/src/components/settingsNew/view/agentSearch/components/ToolsSection.tsx create mode 100644 ui/src/components/settingsNew/view/agentSearch/index.tsx create mode 100644 ui/src/components/settingsNew/view/agentSearch/utils/webSearchConfig.spec.ts create mode 100644 ui/src/components/settingsNew/view/agentSearch/utils/webSearchConfig.ts create mode 100644 ui/src/components/settingsNew/view/extensions/components/AdvancedJsonEditor.tsx create mode 100644 ui/src/components/settingsNew/view/extensions/components/FormEditors.tsx create mode 100644 ui/src/components/settingsNew/view/extensions/components/McpServerFormCard.tsx create mode 100644 ui/src/components/settingsNew/view/extensions/index.tsx create mode 100644 ui/src/components/settingsNew/view/extensions/types/mcp.ts create mode 100644 ui/src/components/settingsNew/view/extensions/utils/constants.ts create mode 100644 ui/src/components/settingsNew/view/extensions/utils/mcpServerForm.ts create mode 100644 ui/src/components/settingsNew/view/general/ChatInputSection.tsx create mode 100644 ui/src/components/settingsNew/view/general/CodeEditorSection.tsx create mode 100644 ui/src/components/settingsNew/view/general/GeneralSettingsSection.tsx create mode 100644 ui/src/components/settingsNew/view/general/index.tsx create mode 100644 ui/src/components/settingsNew/view/integrations/components/GatewayConfigSection.tsx create mode 100644 ui/src/components/settingsNew/view/integrations/im/components/FeishuChannelSection.tsx create mode 100644 ui/src/components/settingsNew/view/integrations/im/components/WeComChannelSection.tsx create mode 100644 ui/src/components/settingsNew/view/integrations/im/components/WeixinChannelSection.tsx create mode 100644 ui/src/components/settingsNew/view/integrations/im/hooks/useGatewayStatus.ts create mode 100644 ui/src/components/settingsNew/view/integrations/im/index.tsx create mode 100644 ui/src/components/settingsNew/view/integrations/im/types/index.ts create mode 100644 ui/src/components/settingsNew/view/integrations/index.tsx create mode 100644 ui/src/components/settingsNew/view/modelPool/components/CatalogPicker.tsx create mode 100644 ui/src/components/settingsNew/view/modelPool/components/ModelsSection.tsx create mode 100644 ui/src/components/settingsNew/view/modelPool/components/ProviderCard.tsx create mode 100644 ui/src/components/settingsNew/view/modelPool/index.tsx create mode 100644 ui/src/components/settingsNew/view/modelPool/types/index.ts create mode 100644 ui/src/components/settingsNew/view/modelPool/utils/configYaml.ts create mode 100644 ui/src/components/settingsNew/view/modelPool/utils/patch.ts create mode 100644 ui/src/components/settingsNew/view/modelPool/utils/providerRefs.ts create mode 100644 ui/src/components/settingsNew/view/officePreview/index.tsx create mode 100644 ui/src/components/settingsNew/view/privacy/components/PermissionControlSection.tsx create mode 100644 ui/src/components/settingsNew/view/privacy/components/PermissionRulesSection.tsx create mode 100644 ui/src/components/settingsNew/view/privacy/components/TelemetrySection.tsx create mode 100644 ui/src/components/settingsNew/view/privacy/index.tsx create mode 100644 ui/src/components/settingsNew/view/privacy/types/index.ts create mode 100644 ui/src/components/settingsNew/view/privacy/utils/constants.ts create mode 100644 ui/src/components/settingsNew/view/privacy/utils/permissions.ts create mode 100644 ui/src/components/settingsNew/view/privacy/utils/telemetry.ts diff --git a/ui/server/routes/update.js b/ui/server/routes/update.js index c2ba8955e..7edada422 100644 --- a/ui/server/routes/update.js +++ b/ui/server/routes/update.js @@ -11,6 +11,11 @@ import { listDesktopReleases, startDesktopUpdateDownload, } from '../services/desktopUpdateService.js'; +import { + normalizeUpdateRuntimeError, + resolveBashExecutable, + resolveRestartCommand, +} from '../services/updateRuntime.js'; const execAsync = promisify(exec); const execFileAsync = promisify(execFile); @@ -303,10 +308,11 @@ router.post('/apply', async (req, res) => { try { const scriptPath = path.join(PROJECT_ROOT, 'scripts', 'update.sh'); + const bashExecutable = await resolveBashExecutable(); sendProgress('start', 'Starting update process...'); - const child = spawn('bash', [scriptPath], { + const child = spawn(bashExecutable, [scriptPath], { cwd: PROJECT_ROOT, env: { ...process.env, FORCE_COLOR: '0' }, stdio: ['ignore', 'pipe', 'pipe'], @@ -342,8 +348,9 @@ router.post('/apply', async (req, res) => { throw new Error(`Update script exited with code ${exitCode}`); } } catch (error) { - sendProgress('error', `Update failed: ${error.message}`, 'error'); - lastUpdateResult = { success: false, error: error.message }; + const message = normalizeUpdateRuntimeError(error); + sendProgress('error', `Update failed: ${message}`, 'error'); + lastUpdateResult = { success: false, error: message }; } finally { updateInProgress = false; res.end(); @@ -361,30 +368,35 @@ router.post('/restart', async (req, res) => { status: 'restarting', }); - setTimeout(() => { - console.log('[update] Spawning replacement process and exiting...'); + setTimeout(async () => { + try { + console.log('[update] Spawning replacement process and exiting...'); - // Spawn `npm run dev` (or the same entry point) as a detached process - const isDocker = process.env.DOCKER === '1' || process.env.container === 'docker'; + // Spawn `npm run dev` (or the same entry point) as a detached process + const isDocker = process.env.DOCKER === '1' || process.env.container === 'docker'; - if (isDocker) { - // In Docker, just exit — the container restart policy handles respawn - process.exit(0); - } + if (isDocker) { + // In Docker, just exit — the container restart policy handles respawn + process.exit(0); + } - // Local: spawn a new server process detached from this one - const projectRoot = path.resolve(PROJECT_ROOT, '..'); - const child = spawn('bash', ['-c', `sleep 2 && cd "${projectRoot}" && npm run dev`], { - cwd: projectRoot, - detached: true, - stdio: 'ignore', - env: { ...process.env }, - windowsHide: process.platform === 'win32', - }); - child.unref(); + // Local: spawn a new server process detached from this one + const projectRoot = path.resolve(PROJECT_ROOT, '..'); + const restartCommand = await resolveRestartCommand({ projectRoot }); + const child = spawn(restartCommand.command, restartCommand.args, { + cwd: projectRoot, + detached: true, + stdio: 'ignore', + env: { ...process.env }, + windowsHide: process.platform === 'win32', + }); + child.unref(); - // Exit after giving the response time to flush - setTimeout(() => process.exit(0), 500); + // Exit after giving the response time to flush + setTimeout(() => process.exit(0), 500); + } catch (error) { + console.error(`[update] Restart failed: ${normalizeUpdateRuntimeError(error)}`); + } }, 1000); }); diff --git a/ui/server/services/updateRuntime.js b/ui/server/services/updateRuntime.js new file mode 100644 index 000000000..0666a18ac --- /dev/null +++ b/ui/server/services/updateRuntime.js @@ -0,0 +1,98 @@ +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +const defaultExecFileAsync = promisify(execFile); + +function appendExistingCandidate(candidates, value, pathExists) { + const candidate = String(value || '').trim(); + if (!candidate || !pathExists(candidate) || candidates.includes(candidate)) return; + candidates.push(candidate); +} + +async function whereExecutables(name, execFileAsync) { + try { + const { stdout } = await execFileAsync('where', [name]); + return String(stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + } catch { + return []; + } +} + +export async function resolveBashExecutable({ + platform = process.platform, + env = process.env, + pathExists = existsSync, + execFileAsync = defaultExecFileAsync, +} = {}) { + const configuredPath = String(env.PILOTDECK_BASH_PATH || '').trim(); + if (configuredPath && pathExists(configuredPath)) return configuredPath; + if (platform !== 'win32') return 'bash'; + + const pathApi = path.win32; + const candidates = []; + for (const entry of String(env.PATH || '').split(pathApi.delimiter)) { + const directory = String(entry || '').trim(); + if (!directory) continue; + appendExistingCandidate(candidates, pathApi.join(directory, 'bash.exe'), pathExists); + } + + for (const candidate of await whereExecutables('bash', execFileAsync)) { + appendExistingCandidate(candidates, candidate, pathExists); + } + + const gitPath = (await whereExecutables('git', execFileAsync))[0]; + if (gitPath) { + const gitRoot = pathApi.resolve(pathApi.dirname(gitPath), '..'); + appendExistingCandidate(candidates, pathApi.join(gitRoot, 'bin', 'bash.exe'), pathExists); + appendExistingCandidate(candidates, pathApi.join(gitRoot, 'usr', 'bin', 'bash.exe'), pathExists); + } + + [ + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', + ].forEach((candidate) => appendExistingCandidate(candidates, candidate, pathExists)); + + return candidates[0] || 'bash'; +} + +export async function resolveRestartCommand({ + platform = process.platform, + env = process.env, + projectRoot, + pathExists = existsSync, + execFileAsync = defaultExecFileAsync, +} = {}) { + if (platform === 'win32') { + return { + command: 'cmd.exe', + args: [ + '/d', + '/s', + '/c', + `timeout /t 2 /nobreak >nul && cd /d "${projectRoot}" && npm run dev`, + ], + }; + } + + const bashExecutable = await resolveBashExecutable({ + platform, + env, + pathExists, + execFileAsync, + }); + return { + command: bashExecutable, + args: ['-c', `sleep 2 && cd "${projectRoot}" && npm run dev`], + }; +} + +export function normalizeUpdateRuntimeError(error) { + if (error?.code === 'ENOENT') { + return 'Unable to locate a bash executable. Install Git Bash and set PILOTDECK_BASH_PATH if needed.'; + } + return error instanceof Error ? error.message : String(error); +} diff --git a/ui/server/services/updateRuntime.test.js b/ui/server/services/updateRuntime.test.js new file mode 100644 index 000000000..fccd917cf --- /dev/null +++ b/ui/server/services/updateRuntime.test.js @@ -0,0 +1,69 @@ +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { + normalizeUpdateRuntimeError, + resolveBashExecutable, + resolveRestartCommand, +} from './updateRuntime.js'; + +describe('update runtime resolution', () => { + it('keeps the default bash command on non-Windows platforms', async () => { + await expect(resolveBashExecutable({ + platform: 'darwin', + env: {}, + pathExists: () => false, + })).resolves.toBe('bash'); + }); + + it('prefers an explicitly configured bash executable', async () => { + await expect(resolveBashExecutable({ + platform: 'win32', + env: { PILOTDECK_BASH_PATH: 'D:\\Tools\\bash.exe' }, + pathExists: (candidate) => candidate === 'D:\\Tools\\bash.exe', + })).resolves.toBe('D:\\Tools\\bash.exe'); + }); + + it('finds bash beside a Windows PATH entry', async () => { + const toolsDirectory = path.win32.join('C:\\', 'Tools'); + const expected = path.win32.join(toolsDirectory, 'bash.exe'); + + await expect(resolveBashExecutable({ + platform: 'win32', + env: { PATH: toolsDirectory }, + pathExists: (candidate) => candidate === expected, + execFileAsync: vi.fn(), + })).resolves.toBe(expected); + }); + + it('falls back to the Git for Windows installation discovered by where git', async () => { + const gitExecutable = path.win32.join('C:\\', 'Program Files', 'Git', 'cmd', 'git.exe'); + const expected = path.win32.join('C:\\', 'Program Files', 'Git', 'bin', 'bash.exe'); + const execFileAsync = vi.fn(async (_command, args) => { + if (args[0] === 'git') return { stdout: `${gitExecutable}\r\n` }; + throw new Error('not found'); + }); + + await expect(resolveBashExecutable({ + platform: 'win32', + env: {}, + pathExists: (candidate) => candidate === expected, + execFileAsync, + })).resolves.toBe(expected); + }); + + it('uses cmd.exe for Windows restarts', async () => { + const command = await resolveRestartCommand({ + platform: 'win32', + projectRoot: 'C:\\PilotDeck', + }); + + expect(command.command).toBe('cmd.exe'); + expect(command.args.at(-1)).toContain('cd /d "C:\\PilotDeck" && npm run dev'); + }); + + it('provides an actionable message when bash cannot be spawned', () => { + expect(normalizeUpdateRuntimeError(Object.assign(new Error('spawn failed'), { + code: 'ENOENT', + }))).toContain('PILOTDECK_BASH_PATH'); + }); +}); diff --git a/ui/src/components/app-shell/AppShellV2.tsx b/ui/src/components/app-shell/AppShellV2.tsx index 07b0d2efa..6ee876400 100644 --- a/ui/src/components/app-shell/AppShellV2.tsx +++ b/ui/src/components/app-shell/AppShellV2.tsx @@ -7,7 +7,7 @@ import { useWebSocket } from '../../contexts/WebSocketContext'; import { useDeviceSettings } from '../../hooks/useDeviceSettings'; import { useSessionProtection } from '../../hooks/useSessionProtection'; import { useProjectsState } from '../../hooks/useProjectsState'; -import Settings from '../settings/view/Settings'; +import Settings from '../settingsNew/SettingsNew'; import ProjectCreationWizard from '../project-creation-wizard'; import { normalizeProjectForSettings, type SettingsProject } from '../../lib/projectSettings'; import { diff --git a/ui/src/components/settingsNew/SettingsNew.tsx b/ui/src/components/settingsNew/SettingsNew.tsx new file mode 100644 index 000000000..7d684f8a7 --- /dev/null +++ b/ui/src/components/settingsNew/SettingsNew.tsx @@ -0,0 +1,128 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { authenticatedFetch } from "../../utils/api"; +import type { SettingsProps } from "./shared/types"; +import type { SettingsNewMenuKey } from "./types"; +import { mapInitialTabToMenuKey } from "./navigation"; +import SettingsNewSidebar from "./view/SettingsNewSidebar"; +import SettingsNewContent from "./view/SettingsNewContent"; + +export type DesktopVersionCheckResult = { + mode: "desktop" | "web"; + hasUpdate: boolean; + checkUnavailable: boolean; + currentVersion: string; + latestVersion: string | null; + latestPublishedAt: string | null; + buildTime: string | null; +}; + +function normalizeDesktopVersionResult(payload: any): DesktopVersionCheckResult { + return { + mode: "desktop", + hasUpdate: Boolean(payload?.hasUpdate), + checkUnavailable: Boolean(payload?.checkUnavailable), + currentVersion: payload?.current?.version ?? "unknown", + latestVersion: payload?.latest?.version ?? null, + latestPublishedAt: payload?.latest?.publishedAt ?? null, + buildTime: payload?.current?.buildTime ?? null, + }; +} + +function normalizeWebVersionResult(payload: any): DesktopVersionCheckResult { + return { + mode: "web", + hasUpdate: Boolean(payload?.hasUpdate), + checkUnavailable: Boolean(payload?.checkUnavailable), + currentVersion: payload?.localHead ?? "unknown", + latestVersion: payload?.remoteHead ?? null, + latestPublishedAt: null, + buildTime: null, + }; +} + +export default function SettingsNew({ + isOpen, + onClose, + projects = [], + initialTab, +}: SettingsProps) { + const isDesktopApp = + typeof window !== "undefined" && !!(window as any).pilotdeckDesktop; + const initialKey = useMemo( + () => mapInitialTabToMenuKey(initialTab), + [initialTab], + ); + const [selectedKey, setSelectedKey] = + useState(initialKey); + const [versionInfo, setVersionInfo] = useState({ + mode: isDesktopApp ? "desktop" : "web", + hasUpdate: false, + checkUnavailable: false, + currentVersion: "unknown", + latestVersion: null, + latestPublishedAt: null, + buildTime: null, + }); + const [checkingVersion, setCheckingVersion] = useState(false); + + const checkVersion = useCallback(async () => { + setCheckingVersion(true); + try { + const res = isDesktopApp + ? await authenticatedFetch("/api/update/desktop/check", { + method: "POST", + }) + : await authenticatedFetch("/api/update/check", { + method: "POST", + }); + if (!res.ok) { + throw new Error("Failed to check version"); + } + const data = await res.json(); + setVersionInfo( + isDesktopApp + ? normalizeDesktopVersionResult(data) + : normalizeWebVersionResult(data), + ); + } catch { + setVersionInfo((prev) => ({ + ...prev, + hasUpdate: false, + checkUnavailable: true, + })); + } finally { + setCheckingVersion(false); + } + }, [isDesktopApp]); + + useEffect(() => { + if (!isOpen) return; + setSelectedKey(mapInitialTabToMenuKey(initialTab)); + void checkVersion(); + }, [isOpen, initialTab, checkVersion]); + + if (!isOpen) { + return null; + } + + return ( +
+
+
+ + +
+
+
+ ); +} diff --git a/ui/src/components/settingsNew/navigation.spec.ts b/ui/src/components/settingsNew/navigation.spec.ts new file mode 100644 index 000000000..a719bc3e2 --- /dev/null +++ b/ui/src/components/settingsNew/navigation.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { mapInitialTabToMenuKey } from "./navigation"; + +describe("mapInitialTabToMenuKey", () => { + it("routes the Office preview deep link to its dedicated page", () => { + expect(mapInitialTabToMenuKey("config:officePreview")).toBe( + "officePreview", + ); + }); + + it("maps legacy top-level settings tabs to the new information architecture", () => { + expect(mapInitialTabToMenuKey("permissions")).toBe("privacy"); + expect(mapInitialTabToMenuKey("mcp")).toBe("mcpServers"); + expect(mapInitialTabToMenuKey("gateway")).toBe("integrations"); + expect(mapInitialTabToMenuKey("config")).toBe("modelPool"); + }); + + it("defaults appearance and unknown tabs to General", () => { + expect(mapInitialTabToMenuKey("appearance")).toBe("general"); + expect(mapInitialTabToMenuKey("unknown")).toBe("general"); + expect(mapInitialTabToMenuKey(undefined)).toBe("general"); + }); +}); diff --git a/ui/src/components/settingsNew/navigation.ts b/ui/src/components/settingsNew/navigation.ts new file mode 100644 index 000000000..934f667f4 --- /dev/null +++ b/ui/src/components/settingsNew/navigation.ts @@ -0,0 +1,24 @@ +import type { SettingsNewMenuKey } from "./types"; + +export function mapInitialTabToMenuKey( + tab: string | undefined, +): SettingsNewMenuKey { + const normalized = String(tab || ""); + if (normalized === "config:officePreview") { + return "officePreview"; + } + + const [base] = normalized.split(":", 1); + switch (base) { + case "permissions": + return "privacy"; + case "mcp": + return "mcpServers"; + case "gateway": + return "integrations"; + case "config": + return "modelPool"; + default: + return "general"; + } +} diff --git a/ui/src/components/settingsNew/shared/components/Inputs.tsx b/ui/src/components/settingsNew/shared/components/Inputs.tsx new file mode 100644 index 000000000..fd22abf2f --- /dev/null +++ b/ui/src/components/settingsNew/shared/components/Inputs.tsx @@ -0,0 +1,447 @@ +import { + createContext, + type KeyboardEvent, + type ReactNode, + useContext, + useEffect, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "../../../../lib/utils"; +import { + isMaskedSecret, + secretDisplayValue, +} from "../utils/secret"; + +type InputSaveMode = "explicit" | "immediate"; + +const InputSaveModeContext = createContext("explicit"); + +export function FieldSaveModeProvider({ + mode, + children, +}: { + mode: InputSaveMode; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +function CommitButtons({ + onSave, + onCancel, + canSave = true, +}: { + onSave: () => void; + onCancel: () => void; + canSave?: boolean; +}) { + const { t } = useTranslation("settings"); + return ( +
+ + +
+ ); +} + +function EditableInputShell({ + value, + onCommit, + canCommit, + children, +}: { + value: string; + onCommit: (next: string) => void; + canCommit?: (draft: string) => boolean; + children: (args: { + editing: boolean; + draft: string; + setDraft: (next: string) => void; + onEditKeyDown: (event: KeyboardEvent) => void; + }) => ReactNode; +}) { + const { t } = useTranslation("settings"); + const mode = useContext(InputSaveModeContext); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value); + + useEffect(() => { + if (!editing) setDraft(value); + }, [editing, value]); + + const isCommitAllowed = canCommit ? canCommit(draft) : true; + + if (mode === "immediate") { + return ( + <> + {children({ + editing: true, + draft: value, + setDraft: onCommit, + onEditKeyDown: () => undefined, + })} + + ); + } + + const save = () => { + if (!isCommitAllowed) return; + onCommit(draft); + setEditing(false); + }; + const cancel = () => { + setDraft(value); + setEditing(false); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + save(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancel(); + } + }; + + return ( +
+
+ {children({ + editing, + draft, + setDraft, + onEditKeyDown: handleKeyDown, + })} +
+
+ {editing ? ( + + ) : ( + + )} +
+
+ ); +} + +export function TextInput({ + value, + onChange, + placeholder, + type = "text", + className, + monospace, +}: { + value: string | number | undefined; + onChange: (next: string) => void; + placeholder?: string; + type?: "text" | "password" | "number"; + className?: string; + monospace?: boolean; +}) { + const stringValue = value === undefined ? "" : String(value); + return ( + + {({ editing, draft, setDraft, onEditKeyDown }) => ( + setDraft(e.target.value)} + onKeyDown={onEditKeyDown} + placeholder={placeholder} + spellCheck={false} + readOnly={!editing} + className={cn( + "w-full rounded-md border border-border bg-background px-2 py-1.5 text-[13px] leading-5 text-foreground outline-none", + editing + ? "border-primary/40 ring-1 ring-ring/40 focus:ring-1 focus:ring-ring" + : "cursor-default bg-muted/40 text-muted-foreground", + monospace && "font-mono text-xs", + className, + )} + /> + )} + + ); +} + +export function SecretTextInput({ + value, + onChange, + placeholder, + emptyPlaceholder, + maskedPlaceholder, + className, + monospace, +}: { + value: string | undefined; + onChange: (next: string) => void; + placeholder?: string; + emptyPlaceholder?: string; + maskedPlaceholder?: string; + className?: string; + monospace?: boolean; +}) { + const masked = isMaskedSecret(value); + return ( + + ); +} + +export function NumberInput({ + value, + onChange, + placeholder, +}: { + value: number | undefined; + onChange: (next: number | undefined) => void; + placeholder?: string; +}) { + const stringValue = value === undefined ? "" : String(value); + return ( + s === "" || Number.isFinite(Number(s))} + onCommit={(s) => { + if (s === "") { + onChange(undefined); + return; + } + const n = Number(s); + if (Number.isFinite(n)) onChange(n); + }} + > + {({ editing, draft, setDraft, onEditKeyDown }) => ( + setDraft(e.target.value)} + onKeyDown={onEditKeyDown} + placeholder={placeholder} + readOnly={!editing} + className={cn( + "w-full rounded-md border border-border bg-background px-2 py-1.5 text-[13px] leading-5 text-foreground outline-none", + editing + ? "border-primary/40 ring-1 ring-ring/40 focus:ring-1 focus:ring-ring" + : "cursor-default bg-muted/40 text-muted-foreground", + )} + /> + )} + + ); +} + +export function TextAreaInput({ + value, + onChange, + placeholder, + className, +}: { + value: string | undefined; + onChange: (next: string) => void; + placeholder?: string; + className?: string; +}) { + const { t } = useTranslation("settings"); + const mode = useContext(InputSaveModeContext); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value ?? ""); + const readonlyValue = value ?? ""; + + useEffect(() => { + if (!editing) setDraft(readonlyValue); + }, [editing, readonlyValue]); + + if (mode === "immediate") { + return ( +