feat: config & onboarding — each teammate connects their own Gitea (Phase A)

The foundation for a shareable team build. Replaces the .env.local-only dev config
with a real, per-teammate connection flow.

main:
- config-store.ts: token encrypted at rest via Electron safeStorage (OS keychain),
  config JSON in userData. Token lives only in main; renderer gets everything but.
- resolveConfig: saved config > .env.local (dev) > null; ignored under COMMITEA_E2E.
  pm-state repo defaults to `${repo}-pm-state`. resetClients() re-reads on change so
  saving config takes effect without a restart. gitea:status gains `demo` (e2e).
- IPC: config:get (no token), config:test (authed read validates token+repo),
  config:set (encrypt+save+reset), config:clear. Model bridge reads config.modelUrl
  and probes reachability — chat is "configured" only if a model actually answers;
  localhost default is dev-only (app.isPackaged gate).

renderer:
- ConnectScreen: real onboarding form (URL/owner/repo/PAT/optional model) → test →
  save. AppShell gates on it: demo → shell (fixtures/e2e); configured → shell (real);
  else → connect. Settings Connection card is real (repo/url/model/sidecar) with
  Reconfigure + Disconnect. Chat cleanly disables with a "no model" state instead of
  the scripted canned reply.

Verified: main + desktop typecheck clean, 14 fixture e2e green (demo mode unchanged),
live onboarding e2e: fresh app → connect form → validated PAT → real board (24 done /
10 open). COMMITEA_NO_ENV_LOCAL + COMMITEA_USERDATA are test hooks for the onboarding path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Croissant Le Doux
2026-07-09 02:09:53 -04:00
parent 770c253625
commit 0dd26b8b05
13 changed files with 534 additions and 69 deletions

View File

@@ -24,8 +24,9 @@ import {
runAgentTurn,
toDirectiveInput,
} from '@commitea/core'
import { ipcMain } from 'electron'
import { app, ipcMain } from 'electron'
import { loadConfig } from './config-store.js'
import {
AGENT_SNAPSHOT_TTL_MS,
appendDirectiveEntry,
@@ -37,7 +38,11 @@ import {
/** Small local model for prose + the read tool; big model reserved for later decomposition. */
function resolveModelRouter(): ModelRouter | null {
if (process.env.COMMITEA_E2E === '1') return null // e2e uses the scripted fixture Reginald
const baseUrl = process.env.COMMITEA_MODEL_URL ?? 'http://localhost:1234/v1'
// Config wins (team build); env is the dev override; localhost is a dev convenience only.
const baseUrl =
loadConfig()?.modelUrl ??
process.env.COMMITEA_MODEL_URL ??
(app.isPackaged ? undefined : 'http://localhost:1234/v1')
if (!baseUrl) return null
return {
small: { baseUrl, model: process.env.COMMITEA_MODEL_SMALL ?? '' },
@@ -45,6 +50,19 @@ function resolveModelRouter(): ModelRouter | null {
}
}
/** Probe the endpoint for a usable model; null when unreachable (chat then stays off). */
async function probeModel(baseUrl: string): Promise<string | null> {
try {
const model = await resolveLoadedModel(baseUrl, process.env.COMMITEA_MODEL_SMALL ?? '')
// resolveLoadedModel only returns a real id when the server answered; the default
// fallback means unreachable, so confirm with a lightweight models call.
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/models`)
return res.ok ? model : null
} catch {
return null
}
}
/**
* Resolve which model to actually ask for. An explicit env override wins;
* otherwise ask the server which model is *loaded* (LM Studio's native
@@ -78,15 +96,15 @@ async function resolveLoadedModel(baseUrl: string, override: string): Promise<st
}
export function registerModelIpc(): void {
const router = resolveModelRouter()
ipcMain.handle('model:status', async () => {
if (!router) return { configured: false, model: null }
const model = await resolveLoadedModel(router.small.baseUrl, router.small.model)
return { configured: true, model }
const r = resolveModelRouter() // resolve fresh so a saved modelUrl takes effect
if (!r) return { configured: false, model: null }
const model = await probeModel(r.small.baseUrl) // only "configured" if a model actually answers
return { configured: !!model, model }
})
ipcMain.handle('model:chat', async (event, messages: ChatMessage[]) => {
const router = resolveModelRouter()
if (!router) return { ok: false as const, reason: 'unconfigured' as const }
const client = getGiteaClient()
const model = await resolveLoadedModel(router.small.baseUrl, router.small.model)
@@ -149,6 +167,7 @@ export function registerModelIpc(): void {
// decomposition (with one loaded local model, that's the loaded one). Returns
// a proposal; nothing is filed until the Capture tray approves it.
ipcMain.handle('model:capture', async (_event, braindump: string) => {
const router = resolveModelRouter()
if (!router) return { ok: false as const, reason: 'unconfigured' as const }
const model = await resolveLoadedModel(router.big.baseUrl, router.big.model)
const chat = createChatClient({ ...router.big, model }, fetch)