Skip to content

feat(desktop): implement settings tabs with multi-provider management#16

Merged
hqhq1025 merged 6 commits intomainfrom
wt/settings-real
Apr 18, 2026
Merged

feat(desktop): implement settings tabs with multi-provider management#16
hqhq1025 merged 6 commits intomainfrom
wt/settings-real

Conversation

@hqhq1025
Copy link
Copy Markdown
Collaborator

Summary

  • Models tab: fully functional multi-provider management — list all saved providers with masked API keys and base URLs; add new providers via an inline modal with live key validation; switch active provider; select per-provider primary / fast models from a hardcoded shortlist; delete providers with an inline confirm step
  • Appearance tab: light / dark theme segmented control (already wired to store) + language selector placeholder (en / zh-CN, stored in config, i18n wiring deferred)
  • Storage tab: shows config, log and data-dir paths with copy and open-folder buttons; red reset-onboarding action (clears secrets, returns to onboarding flow on next launch) with inline confirmation
  • Advanced tab: update channel selector (stable / beta), generation timeout input (default 60 s), toggle-DevTools button

New IPC channels (all in onboarding-ipc.ts)

Channel Purpose
settings:list-providers Returns ProviderRow[] with masked keys
settings:add-provider Encrypts + persists a new provider key
settings:delete-provider Removes a provider, switches active if needed
settings:set-active-provider Sets active provider + model selection
settings:get-paths Returns config / log / data-dir paths
settings:open-folder Opens a path in Finder / Explorer
settings:reset-onboarding Clears secrets from config.toml
settings:toggle-devtools Calls webContents.toggleDevTools()

New config.toml fields

No schema changes — the existing provider, modelPrimary, modelFast, secrets, and baseUrls fields carry all the new state. provider already acts as activeProvider.

Design principles check

  • ✅ Compatibility — no schema migrations needed; reads existing config as-is
  • ✅ Upgradeability — zero new dependencies
  • ✅ No bloat — total diff is ~1 000 lines, all in existing files
  • ✅ Elegance — tokens-only styling, Radix-free (native selects), no any

Test plan

  • Open Settings → Models tab → see the OpenAI provider listed with masked key
  • Click "Add provider" → choose Anthropic → paste key → click Validate → key shows Valid
  • Save → Anthropic row appears; click "Set active" → Anthropic row shows Active badge
  • Select a different primary model → setting persists after reopening Settings
  • Click trash icon on Anthropic → Confirm → row disappears
  • Appearance tab: toggle theme, verify dark mode applies
  • Storage tab: paths show, copy works, Open opens folder
  • Advanced tab: Toggle DevTools opens Chromium panel
  • pnpm --filter @open-codesign/desktop typecheck — no errors
  • pnpm lint — no errors (1 pre-existing warning in PasteKey.tsx unrelated)

@hqhq1025 hqhq1025 closed this Apr 18, 2026
@hqhq1025 hqhq1025 reopened this Apr 18, 2026
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Re-adding a provider after deleting the last saved key keeps a ghost active provider and stale model IDs — settings:delete-provider writes provider: 'openai' when no secrets remain, and settings:add-provider reuses any supported cachedConfig.provider without checking that the active provider still has a saved secret. After deleting the last provider, adding a new one leaves it inactive and onboarding still incomplete, evidence apps/desktop/src/main/onboarding-ipc.ts:243, apps/desktop/src/main/onboarding-ipc.ts:275.
    Suggested fix:

    const current = cachedConfig;
    const hasActiveSecret =
      current !== null && current.secrets[current.provider] !== undefined;
    
    const activeProvider = hasActiveSecret ? current.provider : input.provider;
    const modelPrimary = hasActiveSecret ? current.modelPrimary : input.modelPrimary;
    const modelFast = hasActiveSecret ? current.modelFast : input.modelFast;
  • [Major] Decryption failures are silently masked as *** — the new provider listing catches decryptSecret errors and substitutes an empty string, which hides keychain / safeStorage breakage instead of surfacing it, evidence apps/desktop/src/main/onboarding-ipc.ts:112.
    Suggested fix:

    try {
      plain = decryptSecret(ref.ciphertext);
    } catch (err) {
      throw new CodesignError(`Failed to decrypt key for ${p}`, 'SECRET_DECRYPT_FAILED', {
        cause: err,
      });
    }
  • [Major] The new Language / Update channel / Generation timeout controls silently discard user input — the language select is hard-coded to value="en" with a no-op handler, and the advanced controls only mutate component state. There is no matching preload/main/config persistence path, so values reset on reopen, evidence apps/desktop/src/renderer/src/components/Settings.tsx:705, apps/desktop/src/renderer/src/components/Settings.tsx:854, packages/shared/src/config.ts:30.
    Suggested fix:

    const [locale, setLocale] = useState(currentLocale);
    <NativeSelect
      value={locale}
      onChange={(next) => {
        setLocale(next);
        void window.codesign.settings.setLocale(next);
      }}
    />

    Or, until the persistence path exists, render these controls disabled with a clear "Coming soon" label.

  • [Major] “Open” failures are silent on both IPC and UI sides — shell.openPath returns an error string that must be checked, but settings:open-folder ignores it and the renderer fire-and-forgets the promise. Bad paths or OS failures give the user no feedback, evidence apps/desktop/src/main/onboarding-ipc.ts:335, apps/desktop/src/renderer/src/components/Settings.tsx:774.
    Suggested fix:

    const error = await shell.openPath(raw);
    if (error) {
      throw new CodesignError(`Could not open ${raw}: ${error}`, 'OPEN_PATH_FAILED');
    }

Summary

  • Review mode: initial
  • 4 issues found in the new settings flows: one provider-state regression, two silent-failure paths, and one group of settings controls that currently discard user input.

Testing

  • Not run (automation).
  • Not found in repo/docs: Vitest or Playwright coverage for the new settings:* IPC paths or the new Settings UI flows.

open-codesign Bot

Comment thread apps/desktop/src/main/onboarding-ipc.ts Outdated
delete nextBaseUrls[input.provider];
}
// When adding the first provider, make it active.
const activeProvider =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleting the last provider leaves cachedConfig.provider set to a supported string (openai) even though there is no matching secret, so this branch reuses a ghost active provider and stale model IDs on the next add. The first provider added after that stays inactive and onboarding remains blocked.

Suggested fix:

const current = cachedConfig;
const hasActiveSecret =
  current !== null && current.secrets[current.provider] !== undefined;

const activeProvider = hasActiveSecret ? current.provider : input.provider;
const modelPrimary = hasActiveSecret ? current.modelPrimary : input.modelPrimary;
const modelFast = hasActiveSecret ? current.modelFast : input.modelFast;

Comment thread apps/desktop/src/main/onboarding-ipc.ts Outdated
for (const [p, ref] of Object.entries(cfg.secrets)) {
if (!isSupportedOnboardingProvider(p) || ref === undefined) continue;
let plain: string;
try {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This catch turns keychain / safeStorage failures into a fake masked key (***), which hides the real problem from the user and violates the repo's “no silent fallbacks” rule.

Suggested fix:

try {
  plain = decryptSecret(ref.ciphertext);
} catch (err) {
  throw new CodesignError(`Failed to decrypt key for ${p}`, 'SECRET_DECRYPT_FAILED', {
    cause: err,
  });
}

<div className="pt-2 border-t border-[var(--color-border-subtle)]">
<Row label="Language" hint="UI language. Affects text only, not generation.">
<NativeSelect
value="en"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This select is hard-coded to en and has a no-op onChange. The new advanced controls below are also local component state only, so the settings panel currently discards user input on close/reopen.

Suggested fix:

<NativeSelect
  value={locale}
  onChange={(next) => {
    setLocale(next);
    void window.codesign.settings.setLocale(next);
  }}
/>

Or keep the controls disabled until the persistence path exists.

data: configDir(),
}));

ipcMain.handle('settings:open-folder', async (_e, raw: unknown) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openPath() does not throw on failure; it returns an error string. Ignoring that here, and then fire-and-forgetting the renderer call, makes path-open failures invisible to the user.

Suggested fix:

const error = await shell.openPath(raw);
if (error) {
  throw new CodesignError(`Could not open ${raw}: ${error}`, 'OPEN_PATH_FAILED');
}

@Sun-sunshine06
Copy link
Copy Markdown
Collaborator

目前不建议合并,先处理下面这些问题:

  • DCO check 失败,提交里缺少 Signed-off-by:,需要先补签名。
  • 删除最后一个 provider 后再重新添加,会残留 ghost active provider 和过期 model 配置,导致新增 provider 不真正生效。相关位置:apps/desktop/src/main/onboarding-ipc.ts
  • provider 列表里把解密失败静默吞掉并显示成 ***,这会掩盖 keychain / safeStorage 故障,不符合仓库“不能 silent fallback”的约束。相关位置:apps/desktop/src/main/onboarding-ipc.ts
  • Language / Update channel / Generation timeout 这些新控件当前只是改了前端本地状态,没有真正持久化,重开后会丢用户输入。相关位置:apps/desktop/src/renderer/src/components/Settings.tsxpackages/shared/src/config.ts
  • settings:open-folder 没有处理 shell.openPath 返回的错误字符串,UI 侧也没有把失败反馈给用户,属于静默失败。相关位置:apps/desktop/src/main/onboarding-ipc.tsapps/desktop/src/renderer/src/components/Settings.tsx
  • 当前还缺少对应的 Vitest / Playwright 覆盖,至少应覆盖新的 settings:* IPC 和设置页主要交互路径。

这个 PR 改动面比较大,建议先把上面几项行为和持久化问题收敛完,再考虑合并。

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Debounced model saves outlive the active row and can switch the provider back after the user already moved on. ActiveModelSelector schedules settings:set-active-provider 400 ms later, but the timer is never cleared on unmount. If the user changes a model and immediately clicks "Set active" on another provider, the stale timeout from the old row still fires and re-activates the previous provider, evidence apps/desktop/src/renderer/src/components/Settings.tsx:516, apps/desktop/src/renderer/src/components/Settings.tsx:539, apps/desktop/src/renderer/src/components/Settings.tsx:545.
    Suggested fix:

    useEffect(() => {
      return () => {
        if (saveTimeout.current !== null) clearTimeout(saveTimeout.current);
      };
    }, []);
  • [Minor] The Storage tab's "Open" action does not open folders for the Config and Logs rows. settings:get-paths returns configPath() and getLogPath(), which are file paths, and settings:open-folder passes them directly to shell.openPath(). That opens the file / associated app instead of the containing folder for two of the three rows, evidence apps/desktop/src/main/onboarding-ipc.ts:297, apps/desktop/src/main/onboarding-ipc.ts:298, context apps/desktop/src/main/config.ts:16, apps/desktop/src/main/logger.ts:62.
    Suggested fix:

    import { dirname } from 'node:path';
    
    ipcMain.handle('settings:get-paths', () => ({
      config: configPath(),
      configFolder: dirname(configPath()),
      logs: getLogPath(),
      logsFolder: dirname(getLogPath()),
      data: configDir(),
    }));

    Then wire the Open buttons to the *Folder fields.

Questions

  • Can you add a Signed-off-by trailer to commit 7109ffb194be283a1308db514ce296edaed042c0? The follow-up commit is signed, but the original feature commit is not.

Summary

  • Review mode: follow-up after new commits
  • 2 issues remain on the current head: the debounced active-model save can revert a later provider switch, and the Storage tab still opens files instead of folders for Config/Logs.

Testing

  • Not run (automation).
  • Not found in repo/docs: coverage for the ActiveModelSelector unmount/debounce path or the Storage tab's folder-open behavior; the new Vitest coverage only exercises apps/desktop/src/main/provider-settings.test.ts.

open-codesign Bot

function handlePrimaryChange(v: string) {
setPrimary(v);
if (saveTimeout.current !== null) clearTimeout(saveTimeout.current);
saveTimeout.current = setTimeout(() => void save(v, fast), 400);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This timer is never cleared on unmount. If a user changes a model and immediately activates another provider, the stale timeout from this row still fires 400 ms later and calls settings:set-active-provider for the old provider, flipping the active selection back.

Suggested fix:

useEffect(() => {
  return () => {
    if (saveTimeout.current !== null) clearTimeout(saveTimeout.current);
  };
}, []);

Comment thread apps/desktop/src/main/onboarding-ipc.ts Outdated
if (typeof raw !== 'string') {
throw new CodesignError('open-folder expects a path string', 'IPC_BAD_INPUT');
}
const error = await shell.openPath(raw);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

configPath() and getLogPath() are file paths, so passing them straight to shell.openPath() opens the file / associated app instead of the containing folder promised by this settings action.

Suggested fix:

import { dirname } from 'node:path';

ipcMain.handle('settings:get-paths', () => ({
  config: configPath(),
  configFolder: dirname(configPath()),
  logs: getLogPath(),
  logsFolder: dirname(getLogPath()),
  data: configDir(),
}));

Then use the *Folder fields for the Open buttons.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Add-provider validation can be skipped entirely — canSave becomes true for any non-empty API key, so the modal allows an unvalidated key to be persisted and then shows a success toast. That leaves the first saved provider able to become active with credentials the UI never actually verified, evidence apps/desktop/src/renderer/src/components/Settings.tsx:268, apps/desktop/src/renderer/src/components/Settings.tsx:395.
    Suggested fix:

    const canSave = form.apiKey.trim().length > 0 && form.validated && !form.validating;
  • [Minor] settings:set-active-provider still accepts providers with no stored key — the handler validates the string shape but never checks cfg.secrets[provider] before writing the config, so stale or malformed renderer input can persist an active provider that cannot generate, evidence apps/desktop/src/main/onboarding-ipc.ts:270, apps/desktop/src/main/onboarding-ipc.ts:283.
    Suggested fix:

    if (cfg.secrets[provider] === undefined) {
      throw new CodesignError(
        `No API key stored for provider "${provider}".`,
        'PROVIDER_KEY_MISSING',
      );
    }

Questions

  • Can you add a Signed-off-by trailer to commit 7109ffb194be283a1308db514ce296edaed042c0? The two follow-up commits are signed, but the original feature commit still is not.

Summary

  • Review mode: follow-up after new commits
  • 2 issues remain on the current head: the add-provider flow still bypasses validation, and the main-process activation handler can still persist an active provider with no saved secret.
  • No new dependency, license, or direct provider SDK import issues found in this diff.

Testing

  • Not run (automation).
  • Missing coverage for the add-provider validation gate in Settings.tsx and for rejecting unsaved providers in onboarding-ipc.ts.

open-codesign Bot

const fastOptions = sl.fast.map((m) => ({ value: m, label: m }));
const canSave =
form.apiKey.trim().length > 0 &&
(form.validated || form.baseUrl.trim().length > 0 || form.apiKey.trim().length > 0);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

canSave is effectively just form.apiKey.trim().length > 0, because the final || form.apiKey.trim().length > 0 makes validation optional. That means an invalid or never-validated key can still be saved and marked as successful.

Suggested fix:

const canSave = form.apiKey.trim().length > 0 && form.validated && !form.validating;

Comment thread apps/desktop/src/main/onboarding-ipc.ts Outdated
if (cfg === null) {
throw new CodesignError('No configuration found', 'CONFIG_MISSING');
}
const next: Config = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This handler still writes provider into config without proving a key exists for that provider. If the renderer sends a stale or malformed selection, the app can persist an active provider that immediately fails generation.

Suggested fix:

if (cfg.secrets[provider] === undefined) {
  throw new CodesignError(
    `No API key stored for provider "${provider}".`,
    'PROVIDER_KEY_MISSING',
  );
}

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Deleting the active provider can leave the next active provider with the deleted provider's model IDs, so the app can persist an invalid provider/model pair after a delete, evidence apps/desktop/src/main/onboarding-ipc.ts:244, apps/desktop/src/main/onboarding-ipc.ts:251.
    Suggested fix:

    const nextActive = cfg.provider === raw ? (remaining[0] ?? 'openai') : cfg.provider;
    const defaults = cfg.provider === raw ? PROVIDER_SHORTLIST[nextActive] : null;
    
    const next: Config = {
      version: 1,
      provider: nextActive,
      modelPrimary: defaults?.defaultPrimary ?? cfg.modelPrimary,
      modelFast: defaults?.defaultFast ?? cfg.modelFast,
      secrets: nextSecrets,
      baseUrls: nextBaseUrls,
    };
  • [Major] An in-flight validation response can mark edited credentials as validated. setField clears validated, but handleValidate unconditionally sets it back to true when the old request resolves, so a different API key or base URL can be saved without ever being checked, evidence apps/desktop/src/renderer/src/components/Settings.tsx:224, apps/desktop/src/renderer/src/components/Settings.tsx:243.
    Suggested fix:

    const snapshot = {
      provider: form.provider,
      apiKey: form.apiKey.trim(),
      baseUrl: form.baseUrl.trim(),
    };
    
    const res = await window.codesign.settings.validateKey({
      provider: snapshot.provider,
      apiKey: snapshot.apiKey,
      ...(snapshot.baseUrl ? { baseUrl: snapshot.baseUrl } : {}),
    });
    
    setForm((prev) => {
      const stale =
        prev.provider !== snapshot.provider ||
        prev.apiKey.trim() !== snapshot.apiKey ||
        prev.baseUrl.trim() !== snapshot.baseUrl;
      if (stale) return { ...prev, validating: false };
      return res.ok
        ? { ...prev, validating: false, validated: true }
        : { ...prev, validating: false, validated: false, error: res.message };
    });
  • [Major] The new settings IPC surface is unversioned, which violates the repo's compatibility rule for every IPC contract, evidence docs/PRINCIPLES.md:75, apps/desktop/src/main/onboarding-ipc.ts:202, apps/desktop/src/preload/index.ts:79.
    Suggested fix:

    ipcMain.handle('settings:v1:list-providers', (): ProviderRow[] => {
      return toProviderRows(getCachedConfig(), decryptSecret);
    });
    
    listProviders: () =>
      ipcRenderer.invoke('settings:v1:list-providers') as Promise<ProviderRow[]>,

Questions

  • Can you add a Signed-off-by trailer to commit 7109ffb194be283a1308db514ce296edaed042c0? The follow-up fix commits are signed, but the original feature commit in this PR still is not.

Summary

  • Review mode: follow-up after new commits
  • 3 issues remain on the current head: deleting the active provider can persist invalid model IDs, validation can go stale across edits, and the new settings IPC channels still ship without versioning.
  • No new dependency, license, or direct provider SDK import issues found in this diff.

Testing

  • Not run (automation).
  • Missing coverage for deleting the active provider and for stale validation responses while the form is being edited.

open-codesign Bot

Comment thread apps/desktop/src/main/onboarding-ipc.ts Outdated
const next: Config = {
version: 1,
provider: nextActive,
modelPrimary: cfg.modelPrimary,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If raw is the active provider, this writes nextActive but keeps cfg.modelPrimary / cfg.modelFast from the deleted provider. Deleting OpenAI while Anthropic remains can therefore persist provider = "anthropic" with modelPrimary = "gpt-4o", which breaks the next generation request.

Suggested fix:

const defaults = cfg.provider === raw ? PROVIDER_SHORTLIST[nextActive] : null;

const next: Config = {
  version: 1,
  provider: nextActive,
  modelPrimary: defaults?.defaultPrimary ?? cfg.modelPrimary,
  modelFast: defaults?.defaultFast ?? cfg.modelFast,
  secrets: nextSecrets,
  baseUrls: nextBaseUrls,
};

...(trimmedUrl.length > 0 ? { baseUrl: trimmedUrl } : {}),
});
if (res.ok) {
setForm((prev) => ({ ...prev, validating: false, validated: true }));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setField clears validated, but this async completion marks the current form as validated even if the user changed provider / API key / base URL while the request was in flight. That re-enables Save for credentials that were never actually checked.

Suggested fix:

const snapshot = {
  provider: form.provider,
  apiKey: form.apiKey.trim(),
  baseUrl: form.baseUrl.trim(),
};

const res = await window.codesign.settings.validateKey({
  provider: snapshot.provider,
  apiKey: snapshot.apiKey,
  ...(snapshot.baseUrl ? { baseUrl: snapshot.baseUrl } : {}),
});

setForm((prev) => {
  const stale =
    prev.provider !== snapshot.provider ||
    prev.apiKey.trim() !== snapshot.apiKey ||
    prev.baseUrl.trim() !== snapshot.baseUrl;
  if (stale) return { ...prev, validating: false };
  return res.ok
    ? { ...prev, validating: false, validated: true }
    : { ...prev, validating: false, validated: false, error: res.message };
});


// ── Settings: provider management ──────────────────────────────────────────

ipcMain.handle('settings:list-providers', (): ProviderRow[] => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new settings:* channels are introduced without a version namespace, but docs/PRINCIPLES.md requires every IPC contract to be versioned. Shipping them unversioned now makes the first future rename or payload change a breaking change.

Suggested fix:

ipcMain.handle('settings:v1:list-providers', (): ProviderRow[] => {
  return toProviderRows(getCachedConfig(), decryptSecret);
});

listProviders: () =>
  ipcRenderer.invoke('settings:v1:list-providers') as Promise<ProviderRow[]>,

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Blocker] Deleting the active provider can persist an invalid provider/model pair — when settings:delete-provider switches provider, it still writes the deleted provider's modelPrimary/modelFast, which can break the next generation request (apps/desktop/src/main/onboarding-ipc.ts:270, apps/desktop/src/main/onboarding-ipc.ts:271).
    Suggested fix:

    const switchedActive = cfg.provider === raw;
    const defaults = switchedActive ? PROVIDER_SHORTLIST[nextActive] : null;
    
    const next: Config = {
      version: 1,
      provider: nextActive,
      modelPrimary: defaults?.defaultPrimary ?? cfg.modelPrimary,
      modelFast: defaults?.defaultFast ?? cfg.modelFast,
      secrets: nextSecrets,
      baseUrls: nextBaseUrls,
    };
  • [Major] Async key validation can incorrectly mark edited credentials as validated — handleValidate sets validated: true when the request resolves, even if provider/key/base URL changed while it was in flight (apps/desktop/src/renderer/src/components/Settings.tsx:228, apps/desktop/src/renderer/src/components/Settings.tsx:238).
    Suggested fix:

    const snapshot = {
      provider: form.provider,
      apiKey: form.apiKey.trim(),
      baseUrl: form.baseUrl.trim(),
    };
    
    const res = await window.codesign.settings.validateKey({
      provider: snapshot.provider,
      apiKey: snapshot.apiKey,
      ...(snapshot.baseUrl ? { baseUrl: snapshot.baseUrl } : {}),
    });
    
    setForm((prev) => {
      const stale =
        prev.provider !== snapshot.provider ||
        prev.apiKey.trim() !== snapshot.apiKey ||
        prev.baseUrl.trim() !== snapshot.baseUrl;
      if (stale) return { ...prev, validating: false };
      return res.ok
        ? { ...prev, validating: false, validated: true }
        : { ...prev, validating: false, validated: false, error: res.message };
    });
  • [Major] Newly added IPC contracts are still unversioned, which violates the compatibility requirement for IPC surfaces (apps/desktop/src/main/onboarding-ipc.ts:202, apps/desktop/src/main/preferences-ipc.ts:86).
    Suggested fix:

    ipcMain.handle('settings:v1:list-providers', () => {
      return toProviderRows(getCachedConfig(), decryptSecret);
    });
    
    ipcMain.handle('preferences:v1:get', async () => readPersisted());
  • [Major] New UI markup introduces hardcoded visual literals (text-white, text-[10px]) instead of tokenized values, violating the token-only UI rule (apps/desktop/src/renderer/src/components/Settings.tsx:435, apps/desktop/src/renderer/src/components/Settings.tsx:440).
    Suggested fix:

    <span className="inline-flex items-center px-1.5 py-0.5 rounded-full bg-[var(--color-accent)] text-[var(--color-text-inverse)] text-[var(--text-2xs)] font-medium leading-none">
      Active
    </span>

Summary

  • Review mode: follow-up after new commits
  • 4 issues remain on the current head: active-provider delete keeps stale models, async validation race, unversioned new IPC channels, and token-rule violations in newly added UI classes.

Testing

  • Not run (automation).
  • Missing coverage for deleting the active provider with provider switch, and for stale validation responses while form fields change mid-request.

open-codesign Bot

Comment thread apps/desktop/src/main/onboarding-ipc.ts Outdated
const next: Config = {
version: 1,
provider: nextActive,
modelPrimary: cfg.modelPrimary,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switching active provider here keeps cfg.modelPrimary/cfg.modelFast from the deleted provider, which can persist an invalid provider/model pair.

Suggested fix:

const switchedActive = cfg.provider === raw;
const defaults = switchedActive ? PROVIDER_SHORTLIST[nextActive] : null;

const next: Config = {
  version: 1,
  provider: nextActive,
  modelPrimary: defaults?.defaultPrimary ?? cfg.modelPrimary,
  modelFast: defaults?.defaultFast ?? cfg.modelFast,
  secrets: nextSecrets,
  baseUrls: nextBaseUrls,
};

...(trimmedUrl.length > 0 ? { baseUrl: trimmedUrl } : {}),
});
if (res.ok) {
setForm((prev) => ({ ...prev, validating: false, validated: true }));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This async completion can mark edited credentials as validated if provider/key/base URL changes while validation is in flight.

Suggested fix:

const snapshot = {
  provider: form.provider,
  apiKey: form.apiKey.trim(),
  baseUrl: form.baseUrl.trim(),
};

const res = await window.codesign.settings.validateKey({
  provider: snapshot.provider,
  apiKey: snapshot.apiKey,
  ...(snapshot.baseUrl ? { baseUrl: snapshot.baseUrl } : {}),
});

setForm((prev) => {
  const stale =
    prev.provider !== snapshot.provider ||
    prev.apiKey.trim() !== snapshot.apiKey ||
    prev.baseUrl.trim() !== snapshot.baseUrl;
  if (stale) return { ...prev, validating: false };
  return res.ok
    ? { ...prev, validating: false, validated: true }
    : { ...prev, validating: false, validated: false, error: res.message };
});


// ── Settings: provider management ──────────────────────────────────────────

ipcMain.handle('settings:list-providers', (): ProviderRow[] => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New settings IPC channels are unversioned. The project compatibility rule requires versioned IPC contracts to avoid future breaking changes.

Suggested fix:

ipcMain.handle(settings:v1:list-providers, () => {
  return toProviderRows(getCachedConfig(), decryptSecret);
});

}

export function registerPreferencesIpc(): void {
ipcMain.handle('preferences:get', async (): Promise<Preferences> => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These newly added preferences IPC channels are also unversioned; they should follow the same :v1: convention as other public contracts.

Suggested fix:

ipcMain.handle(preferences:v1:get, async (): Promise<Preferences> => {
  return readPersisted();
});

{label}
</span>
{row.isActive && !hasError && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full bg-[var(--color-accent)] text-white text-[10px] font-medium leading-none">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new class uses hardcoded visual literals (text-white, text-[10px]), which violates the token-only UI rule.

Suggested fix:

<span className="inline-flex items-center px-1.5 py-0.5 rounded-full bg-[var(--color-accent)] text-[var(--color-text-inverse)] text-[var(--text-2xs)] font-medium leading-none">
  Active
</span>

hqhq1025 added a commit that referenced this pull request Apr 18, 2026
…te against stale form

- Extract computeDeleteProviderResult pure helper to provider-settings.ts;
  when the active provider is deleted the new active provider's PROVIDER_SHORTLIST
  defaults are used for modelPrimary/modelFast instead of carrying over the old
  provider's model IDs (Blocker from #16 review).
- Tombstone path (last provider removed) now writes empty model strings so a
  stale model ID is never persisted.
- Guard handleValidate in AddProviderModal: snapshot provider/apiKey/baseUrl
  before the async call and discard the result via applyValidateResult if the
  form changed while awaiting (Major race fix from #16 review).
- Add applyValidateResult pure exported helper for unit testing without a DOM.
- 5 new tests (3 computeDeleteProviderResult + 4 applyValidateResult, total 15).

Signed-off-by: hqhq1025 <1506751656@qq.com>
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] New settings IPC contracts are unversioned, which violates the compatibility requirement for IPC surfaces and makes future contract evolution breaking by default. Evidence apps/desktop/src/main/onboarding-ipc.ts:203, apps/desktop/src/preload/index.ts:93
    Suggested fix:

    // main
    ipcMain.handle('settings:v1:list-providers', () => toProviderRows(getCachedConfig(), decryptSecret));
    
    // preload
    listProviders: () => ipcRenderer.invoke('settings:v1:list-providers') as Promise<ProviderRow[]>,
  • [Major] New preferences IPC contracts are also unversioned; this introduces another public contract without versioning and conflicts with the same compatibility rule. Evidence apps/desktop/src/main/preferences-ipc.ts:86, apps/desktop/src/preload/index.ts:122
    Suggested fix:

    // main
    ipcMain.handle('preferences:v1:get', async () => readPersisted());
    ipcMain.handle('preferences:v1:update', async (_e, raw: unknown) => {
      const patch = parsePreferences(raw);
      const current = await readPersisted();
      const next: Preferences = { ...current, ...patch };
      await writePersisted(next);
      return next;
    });
    
    // preload
    get: () => ipcRenderer.invoke('preferences:v1:get') as Promise<Preferences>,
    update: (patch: Partial<Preferences>) =>
      ipcRenderer.invoke('preferences:v1:update', patch) as Promise<Preferences>,
  • [Major] New Settings badges/buttons use hardcoded visual literals (text-white, text-[10px]) instead of UI tokens, violating the token-only UI rule and causing style drift risk. Evidence apps/desktop/src/renderer/src/components/Settings.tsx:474, apps/desktop/src/renderer/src/components/Settings.tsx:479, apps/desktop/src/renderer/src/components/Settings.tsx:488
    Suggested fix:

    <span className="inline-flex items-center px-1.5 py-0.5 rounded-full bg-[var(--color-accent)] text-[var(--color-text-inverse)] text-[var(--text-2xs)] font-medium leading-none">
      Active
    </span>

Summary

  • Review mode: follow-up after new commits
  • 3 issues remain on the current head: two newly introduced unversioned IPC surfaces (settings + preferences) and token-rule violations in new Settings UI classes.

Testing

  • Not run (automation).
  • Missing coverage/guardrails for IPC version naming conventions and token-only class usage in newly added UI markup.

open-codesign Bot


// ── Settings: provider management ──────────────────────────────────────────

ipcMain.handle('settings:list-providers', (): ProviderRow[] => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New settings IPC channels are introduced unversioned (settings:list-providers, etc.). This breaks the compatibility/versioning rule for IPC contracts.

Suggested fix:

ipcMain.handle("settings:v1:list-providers", () => toProviderRows(getCachedConfig(), decryptSecret));

}

export function registerPreferencesIpc(): void {
ipcMain.handle('preferences:get', async (): Promise<Preferences> => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

preferences:get/preferences:update are newly introduced public IPC contracts without versioning.

Suggested fix:

ipcMain.handle("preferences:v1:get", async () => readPersisted());
ipcMain.handle("preferences:v1:update", async (_e, raw: unknown) => { /* ... */ });

{label}
</span>
{row.isActive && !hasError && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full bg-[var(--color-accent)] text-white text-[10px] font-medium leading-none">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new UI class uses hardcoded literals (text-white, text-[10px]), which violates the token-only UI rule.

Suggested fix:

<span className="... text-[var(--color-text-inverse)] text-[var(--text-2xs)] ...">Active</span>

hqhq1025 added a commit that referenced this pull request Apr 18, 2026
…te against stale form

- Extract computeDeleteProviderResult pure helper to provider-settings.ts;
  when the active provider is deleted the new active provider's PROVIDER_SHORTLIST
  defaults are used for modelPrimary/modelFast instead of carrying over the old
  provider's model IDs (Blocker from #16 review).
- Tombstone path (last provider removed) now writes empty model strings so a
  stale model ID is never persisted.
- Guard handleValidate in AddProviderModal: snapshot provider/apiKey/baseUrl
  before the async call and discard the result via applyValidateResult if the
  form changed while awaiting (Major race fix from #16 review).
- Add applyValidateResult pure exported helper for unit testing without a DOM.
- 5 new tests (3 computeDeleteProviderResult + 4 applyValidateResult, total 15).

Signed-off-by: hqhq1025 <1506751656@qq.com>
…rences persistence

Implements all four settings tabs (Models, Appearance, Storage, Advanced) with
full persistence and correct error handling.

Key changes:
- Settings UI: four-tab panel (Models / Appearance / Storage / Advanced) with
  provider cards, add/delete/activate flows, model selector, path viewer, reset
- toProviderRows: soft-fail on safeStorage decryption — returns error:'decryption_failed'
  row instead of throwing; UI shows red badge + 'Re-enter key' button
- preferences-ipc.ts: new IPC module persisting updateChannel and generationTimeoutSec
  to ~/.config/open-codesign/preferences.json (schemaVersion:1)
- locale-ipc.ts and preferences-ipc.ts registered in main/index.ts
- preload: settings, preferences, and locale namespaces exposed to renderer
- AppearanceTab: language select reads/writes via locale:get-current / locale:set
- AdvancedTab: update channel and generation timeout read/write via preferences IPC
- ActiveModelSelector: 400ms debounce with proper useEffect cleanup on unmount
- delete-provider: fix ghost active provider — when all providers removed, write
  tombstone config (empty secrets) instead of falling back to hardcoded 'openai';
  re-adding a new provider correctly auto-activates it
- provider-settings.ts and onboarding-ipc.ts: use electron-runtime import pattern
- Tests: 8 total (5 new), covering decryption-failed row, masked-key row, ghost-provider
  auto-activate, provider preservation, assertProviderHasStoredSecret

Signed-off-by: hqhq1025 <1506751656@qq.com>
…te against stale form

- Extract computeDeleteProviderResult pure helper to provider-settings.ts;
  when the active provider is deleted the new active provider's PROVIDER_SHORTLIST
  defaults are used for modelPrimary/modelFast instead of carrying over the old
  provider's model IDs (Blocker from #16 review).
- Tombstone path (last provider removed) now writes empty model strings so a
  stale model ID is never persisted.
- Guard handleValidate in AddProviderModal: snapshot provider/apiKey/baseUrl
  before the async call and discard the result via applyValidateResult if the
  form changed while awaiting (Major race fix from #16 review).
- Add applyValidateResult pure exported helper for unit testing without a DOM.
- 5 new tests (3 computeDeleteProviderResult + 4 applyValidateResult, total 15).

Signed-off-by: hqhq1025 <1506751656@qq.com>
- Add settings:v1:* and preferences:v1:* versioned IPC channels (8 settings + 2 prefs)
- Keep legacy settings:* and preferences:* channels as shims with logger.warn
- Preload now calls v1 channels exclusively
- Extract per-handler run* helpers shared by v1 and legacy registrations
- Replace text-white with text-[var(--color-on-accent)] in Settings badges/buttons
- Replace text-[10px] with text-[var(--font-size-badge)] in ProviderCard badges
- Add --color-on-accent and --font-size-badge tokens to packages/ui/src/tokens.css
- Add onboarding-ipc.test.ts: 3 tests covering v1 channel registration and legacy compat

Signed-off-by: hqhq1025 <1506751656@qq.com>
- Settings.tsx locale-load .catch(() => {}) → pushToast error
- Settings.tsx preferences bootstrap .catch(() => {}) → pushToast error
- preferences-ipc.ts non-ENOENT read errors → throw CodesignError (PREFERENCES_READ_FAILED)
- packages/core generate() empty artifacts → throw CodesignError (OUTPUT_MISSING_ARTIFACT)
- Add pushIframeError to CodesignState interface (was implemented but missing from type)
- Fix store.test.ts: remove stale designSystem field not present in OnboardingState
- Add preferences-ipc.test.ts: 2 tests covering ENOENT default and non-ENOENT throw
- Add generate.test.ts: 4 tests covering OUTPUT_MISSING_ARTIFACT error path

Signed-off-by: hqhq1025 <1506751656@qq.com>
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • No actionable issues found on added/modified lines in the current head.

Summary

  • Review mode: follow-up after new commits
  • No new issues found in this pass.
  • Residual risk: the new settings/provider flows are covered by Vitest, but end-to-end behavior for Settings tabs (Models/Storage/Advanced) and IPC wiring is still not covered by Playwright in this PR.

Testing

  • Not run (automation)
  • Suggested: add one Playwright smoke test that opens Settings and exercises provider add/switch/delete + preferences read/write roundtrip.

open-codesign Bot

Add @open-codesign/i18n workspace dep, LocalInputFile/ElementSelection
types from main, and fix sendPrompt call signature in App.tsx to match
the object-input type declared in store.ts.
The PR commits were originally based on an older version of the codebase
and accidentally removed features added by PRs #18 and #20:

- Restore design-system.ts and prompt-context.ts (deleted in branch)
- Restore InlineCommentComposer.tsx and PreviewPane.test.ts (deleted)
- Restore StoredDesignSystem / designSystem support in config.ts
- Re-export StoredDesignSystem + STORED_DESIGN_SYSTEM_SCHEMA_VERSION from shared
- Re-add getOnboardingState() / setDesignSystem() to onboarding-ipc.ts
  (these are needed by main/index.ts design-system IPC handlers)
- Add full import set to main/index.ts (stat, basename, applyComment,
  dialog, scanDesignSystem, getCachedConfig, getOnboardingState,
  setDesignSystem, preparePromptContext)
- Restore preload/index.ts: add back applyComment, pickInputFiles,
  pickDesignSystemDirectory, clearDesignSystem methods removed in PR;
  keep PR-added settings/preferences namespaces and interfaces
- Restore all other files regressed to pre-#18/#20 state (Sidebar.tsx,
  store.ts, App.tsx, core/index.ts, providers, templates, ui tokens, etc.)
- Fix Settings.tsx formatting (spurious blank line)
- Fix store.test.ts READY_CONFIG missing designSystem field
@hqhq1025 hqhq1025 merged commit ccb5aad into main Apr 18, 2026
6 of 11 checks passed
@hqhq1025 hqhq1025 deleted the wt/settings-real branch April 18, 2026 15:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants