Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- [ ] `npm run lint` passes
- [ ] `npm test` passes (runs `npm run compile` first so `electron/bundle-guard.test.ts` actually executes — `npm run test:bundle` to run just that)
- [ ] `npm run test:e2e` passes (run before merging UI changes or cutting a release)
- [ ] If this ships a **major, user-facing feature**: added an entry to `scripts/features.config.js` so it appears in the "What's New" modal (generated JSON — run `node scripts/generate-features.js`)
- [ ] No hardcoded colours — CSS variables only (`var(--accent)`, `var(--text-primary)`, etc.)
- [ ] No `text-[Npx]` pixel font classes — rem equivalents only (`text-[0.714rem]`, `text-xs`, etc.)
- [ ] New IPC handlers wrapped in `handle()` and return `IpcResult<T>`
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ jobs:
- name: Generate licenses (required by type check)
run: node scripts/generate-licenses.js

- name: Generate What's New feature registry (required by type check)
run: node scripts/generate-features.js

- name: Type check
run: npm run type-check:all

Expand Down Expand Up @@ -101,6 +104,8 @@ jobs:
cp node_modules/better-sqlite3/build/Release/better_sqlite3.node vitest-native/

- name: Run tests
# `npm test` generates licenses + the What's New registry (imported by app
# code under test) and compiles before running vitest.
run: npm test

# ── Build ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -139,6 +144,9 @@ jobs:
- name: Generate licenses
run: node scripts/generate-licenses.js

- name: Generate What's New feature registry
run: node scripts/generate-features.js

- name: Build
run: npx cross-env ELECTRON_BUILD=true npx next build

Expand Down Expand Up @@ -186,6 +194,11 @@ jobs:
- name: Install Playwright system dependencies
run: npx playwright install-deps chromium

- name: Generate licenses + What's New registry (baked into the built app)
run: |
node scripts/generate-licenses.js
node scripts/generate-features.js

- name: Run E2E tests
run: npm run test:e2e

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
node scripts/generate-licenses.js
node scripts/generate-features.js
npx cross-env ELECTRON_BUILD=true npx next build
npm run compile
npm run smoke-test
Expand Down Expand Up @@ -250,6 +251,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
node scripts/generate-licenses.js
node scripts/generate-features.js
npx cross-env ELECTRON_BUILD=true npx next build
npm run compile
npm run smoke-test
Expand Down Expand Up @@ -318,6 +320,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
node scripts/generate-licenses.js
node scripts/generate-features.js
npx cross-env ELECTRON_BUILD=true npx next build
npm run compile
npm run smoke-test
Expand Down
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ Procedure for the relevant target (desktop and/or mobile):

This guarantees `release.sh`'s gate (`changelogs/v<next>.md` must exist for the chosen bump) is satisfied, so the pending changelog and the version bump stay in lockstep — you never have to be told which changelog to touch.

### "What's New" modal rule (announce major features)

The What's New modal (`src/components/layout/NewFeatureModal.tsx`) is generated from a curated source, exactly like `licenses.json`:

- **Source of truth**: `scripts/features.config.js` (the `FEATURES` array). Hand-authored.
- **Generator**: `scripts/generate-features.js` → `src/generated/new-features.json` (git-ignored, baked into the static export). Runs in `build.js`, the `dev` script, and CI (next to `generate-licenses.js`).
- **Runtime**: `src/lib/new-features-registry.ts` reads the generated JSON — never edit the registry by hand.

**When you ship a MAJOR, user-facing feature, add an entry to `scripts/features.config.js`** (not every changelog line — only headline features worth announcing on launch; fixes and internal work stay in `changelogs/` only). Append at the end in release order with a stable, never-reused `id` (e.g. `v2.6.0-my-feature`), the release `version` (`vX.Y.Z`), a short `title`, a one-word-ish `category`, a `description`, and 3–4 `highlights` (a `"Prefix: rest"` highlight bolds the prefix). The modal auto-shows unseen entries belonging to the newest `version` present. Run `node scripts/generate-features.js` after editing; it validates the shape and rejects duplicate ids.

## Views and navigation

| View | Key | Component |
Expand Down
7 changes: 6 additions & 1 deletion changelogs/v2.5.9.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@

## Changed

- **Cleaner endpoint settings.** Now that saved providers cover switching endpoints, the old OpenAI / Ollama / LM Studio quick-preset pills under the Base URL field have been removed from both the AI settings and the Coding Agent settings — pick or add a saved provider instead. The provider picker now uses Cairn's standard dropdown menu (matching the model picker and other settings) instead of a plain OS select box, and shows each provider's model beneath its name.
- **Cleaner endpoint settings.** Now that saved providers cover switching endpoints, the old OpenAI / Ollama / LM Studio quick-preset pills under the Base URL field have been removed from both the AI settings and the Coding Agent settings — pick or add a saved provider instead. The provider picker now uses Cairn's standard dropdown menu (matching the model picker and other settings) instead of a plain OS select box, and shows each provider's model beneath its name. The standalone Base URL / API Key / Model rows are gone too — a provider's connection is edited entirely inside its Add/Edit form.
- **Consistent model picker everywhere.** The model selector from the in-chat quick settings — a searchable dropdown of the endpoint's models with a Refresh action and a "Custom model…" option for anything the endpoint doesn't list — is now used across the app: in Settings → AI, in the Coding Agent settings, and in the saved-provider Add/Edit form. This replaces the older text box + Fetch button + wall of model pills, so choosing a model looks and behaves the same in every place.
- **The model dropdown only shows real models.** It no longer pre-fills with a hardcoded list of guessed model names. Instead each endpoint's model list is fetched and cached per endpoint (base URL + key), so it appears instantly the next time you open the picker; if nothing is cached yet, it fetches once automatically. You can still Refresh to re-query or enter a custom model id. Cached lists refresh after a week.
- **Pick the model for the active provider — separately for Chat and the Agent.** The connection (endpoint + key) is shared across your saved providers, but the model is now a per-surface choice: there's a Model picker right under the provider switcher in both Settings → AI and the Coding Agent settings, so you can change the active provider's model without opening its Edit form. Because the choice lives with each surface, AI Chat and the Coding Agent can run **different models on the same provider** (e.g. the agent on a larger model, chat on a cheaper one). Each provider still has a "default model" that seeds a surface the first time it selects that provider.

## Security

- **AI provider API keys now live in the OS keychain.** Keys for the cloud/local AI providers (chat + coding agent, one per saved provider) are stored encrypted in your operating system's keychain (macOS Keychain, Windows DPAPI, Linux libsecret) — the same secure store already used for MCP/service credentials — instead of in plain text in the app's settings files or local storage. The app only ever holds an opaque reference token; the real key is decrypted in the background process at request time, and never written to disk unencrypted, kept in the window, or exposed to web content. Existing keys are relocated into the keychain automatically on first launch and scrubbed from the old plaintext locations. Fetching an endpoint's model list now also happens in the background process, so your key isn't handled by the in-app browser layer. The API-key field shows only whether a key is stored ("•••• stored — type to replace"), never the saved key.

## Fixed

Expand Down
52 changes: 39 additions & 13 deletions electron/ipc/ai-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@ import { registerIpcHandle } from "./registry";
import { err, handle, type DbContext } from "./result-helpers";
import { generatePrd } from "../lib/prd";
import { callLLM, isLocalEndpoint, normaliseBaseUrl, type LLMConfig } from "../lib/llm";
import { saveCachedConfig, getCachedConfig } from "../lib/config-cache";
import { getCachedConfig, cacheLlmConnection } from "../lib/config-cache";
import { resolveLlmApiKey } from "../lib/secure-store";

function resolveConfig(
config: { baseUrl?: string; model?: string; apiKey?: string } | undefined,
cacheKey: "ai" | "agent"
): { error: string } | LLMConfig {
const cached = cacheKey === "ai" ? getCachedConfig().aiConfig : getCachedConfig().agentConfig;

// Merge request config with cached config, always merging cached values if not provided.
// Merge request config with cached config, always merging cached values if not
// provided. `apiKey` here is a keychain reference token (or empty), not a raw key.
const mergedConfig = {
baseUrl: config?.baseUrl || cached?.baseUrl || "",
model: config?.model || cached?.model || "",
Expand All @@ -28,13 +30,14 @@ function resolveConfig(

const baseUrl = normaliseBaseUrl(mergedConfig.baseUrl || "https://api.openai.com");
const model = mergedConfig.model || "gpt-4o-mini";
const apiKey = mergedConfig.apiKey || "";
const keyRef = mergedConfig.apiKey || "";
const isLocal = isLocalEndpoint(baseUrl);
if (!apiKey && !isLocal) {
if (!keyRef && !isLocal) {
const sectionName = cacheKey === "ai" ? "Settings → AI & Chat" : "Settings";
return { error: `AI is not configured. Add an API key in ${sectionName}, or use a local endpoint.` };
}
return { baseUrl, model, apiKey };
// Resolve the ref to the real key only now, for this request.
return { baseUrl, model, apiKey: resolveLlmApiKey(keyRef) };
}

function cleanOutput(text: string): string {
Expand Down Expand Up @@ -98,21 +101,44 @@ export function registerAiHandlers(ctx: DbContext): void {
});
});

// ── Model discovery (GET {baseUrl}/v1/models) ─────
// Runs in the main process so the API key (a keychain ref) is resolved here
// and the raw key never lives in the renderer or crosses the CSP boundary.
registerIpcHandle(
"ai:fetchModels",
async (_e, args: { baseUrl?: string; apiKey?: string }) =>
handle(async () => {
const url = normaliseBaseUrl(args.baseUrl || "https://api.openai.com");
const realKey = resolveLlmApiKey(args.apiKey);
const headers: Record<string, string> = {};
if (realKey) headers["Authorization"] = `Bearer ${realKey}`;
// Abort a hung endpoint after 12s so the picker's spinner can't hang forever.
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 12_000);
try {
const res = await fetch(`${url}/v1/models`, { headers, signal: ac.signal });
if (!res.ok) throw new Error(`${res.status}`);
const data = (await res.json()) as { data?: Array<{ id: string }> };
return (data?.data ?? [])
.map((m) => m.id)
.filter((id) => !id.includes("embed") && !id.includes("whisper") && !id.includes("tts") && !id.includes("dall-e"))
.sort();
} finally {
clearTimeout(timer);
}
})
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

// ── AI PRD generation (direct, no chat loop) ──────
registerIpcHandle("ai:generatePrd", async (_e, args: {
projectId: string;
title: string;
requirements: string;
config: { baseUrl: string; model: string; apiKey: string };
}) => {
// PRD returns its own { error } shape for user-facing validation errors
if (args.config?.apiKey) {
saveCachedConfig("ai", {
baseUrl: args.config.baseUrl,
model: args.config.model,
apiKey: args.config.apiKey,
});
}
// PRD returns its own { error } shape for user-facing validation errors.
// Cache the connection (apiKey scrubbed to a ref-or-clear by the cache layer).
cacheLlmConnection("ai", args.config);

const resolved = resolveConfig(args.config, "ai");
if ("error" in resolved) {
Expand Down
18 changes: 8 additions & 10 deletions electron/ipc/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ import type Database from "better-sqlite3";
import { isLocalEndpoint, normaliseBaseUrl, type OpenAIMessage, calculatePromptBreakdown, scaleBreakdown, type TokenBreakdown } from "../lib/llm";
import { TOOLS, buildSystemPrompt, type ChatRequest } from "../lib/tools";
import { getExternalToolDefs } from "../lib/external-tools";
import { saveCachedConfig, getCachedConfig } from "../lib/config-cache";
import { getCachedConfig, cacheLlmConnection } from "../lib/config-cache";
import { runToolLoop } from "../lib/chat-loop";
import { resolveLlmApiKey } from "../lib/secure-store";

// Track one AbortController per renderer webContents ID
const abortControllers = new Map<number, AbortController>();
Expand All @@ -31,14 +32,9 @@ function resolveAIConfig(config?: {
model: string;
apiKey: string;
} {
if (config?.apiKey) {
saveCachedConfig("ai", {
provider: config.provider,
baseUrl: config.baseUrl,
model: config.model,
apiKey: config.apiKey,
});
}
// Persist the connection (provider/baseUrl/model + a keychain-ref apiKey only;
// cacheLlmConnection scrubs any raw key). The renderer sends a ref, not a key.
cacheLlmConnection("ai", config);

let reqConfig = config;
const isLocal = config?.baseUrl ? isLocalEndpoint(normaliseBaseUrl(config.baseUrl)) : false;
Expand All @@ -59,7 +55,9 @@ function resolveAIConfig(config?: {
provider: reqConfig?.provider ?? "openai",
baseUrl: normaliseBaseUrl(reqConfig?.baseUrl ?? "https://api.openai.com"),
model: reqConfig?.model ?? "gpt-4o-mini",
apiKey: reqConfig?.apiKey ?? "",
// Resolve the keychain ref (or pass a literal through) to the real key used
// for this request only. Never stored anywhere from here on.
apiKey: resolveLlmApiKey(reqConfig?.apiKey),
};
}

Expand Down
19 changes: 8 additions & 11 deletions electron/ipc/flow-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import { registerIpcHandle } from "./registry";
import { handle, type DbContext } from "./result-helpers";
import * as q from "../db/queries";
import { isLocalEndpoint, callLLM, normaliseBaseUrl } from "../lib/llm";
import { saveCachedConfig, getCachedConfig } from "../lib/config-cache";
import { getCachedConfig, cacheLlmConnection } from "../lib/config-cache";
import { resolveLlmApiKey } from "../lib/secure-store";

/**
* Resolve the effective AI config (cache fallback + normalisation).
Expand Down Expand Up @@ -46,12 +47,13 @@ function resolveAiConfig(input: { baseUrl?: string; model?: string; apiKey?: str

const baseUrl = normaliseBaseUrl(reqConfig?.baseUrl || "https://api.openai.com");
const model = reqConfig?.model || "gpt-4o-mini";
const apiKey = reqConfig?.apiKey || "";
const keyRef = reqConfig?.apiKey || "";
const isLocal = isLocalEndpoint(baseUrl);
if (!apiKey && !isLocal) {
if (!keyRef && !isLocal) {
return { error: "AI is not configured. Add an API key in Settings → AI & Chat, or use a local endpoint." };
}
return { baseUrl, model, apiKey };
// `keyRef` is a keychain reference token; resolve to the real key for this request only.
return { baseUrl, model, apiKey: resolveLlmApiKey(keyRef) };
}

export function registerFlowHandlers(ctx: DbContext): void {
Expand Down Expand Up @@ -102,13 +104,8 @@ export function registerFlowHandlers(ctx: DbContext): void {
"db:flow:node:summarize",
(_e, args: { nodeId: string; config: { baseUrl: string; model: string; apiKey: string } }) =>
handle(async () => {
if (args.config?.apiKey) {
saveCachedConfig("ai", {
baseUrl: args.config.baseUrl,
model: args.config.model,
apiKey: args.config.apiKey,
});
}
// Cache the connection (apiKey scrubbed to a ref-or-clear by the cache layer).
cacheLlmConnection("ai", args.config);

const resolved = resolveAiConfig(args.config);
if ("error" in resolved) throw new Error(resolved.error);
Expand Down
Loading