From 0dd26b8b05faf07a0e25d4caafb2cf45ddee8821 Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Thu, 9 Jul 2026 02:09:53 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20config=20&=20onboarding=20=E2=80=94=20e?= =?UTF-8?q?ach=20teammate=20connects=20their=20own=20Gitea=20(Phase=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/desktop/e2e/live-onboarding.spec.ts | 56 +++++++++ apps/desktop/e2e/smoke.spec.ts | 2 +- apps/desktop/src/main/config-store.ts | 101 +++++++++++++++ apps/desktop/src/main/gitea.ts | 110 ++++++++++++---- apps/desktop/src/main/index.ts | 3 + apps/desktop/src/main/model.ts | 33 +++-- apps/desktop/src/preload/index.ts | 10 ++ .../src/components/screens/connect-screen.tsx | 117 ++++++++++++++++++ .../components/screens/settings-screen.tsx | 61 ++++++--- .../src/components/shell/app-shell.tsx | 46 ++++++- .../src/components/shell/chat-panel.tsx | 14 +-- apps/desktop/src/renderer/src/global.d.ts | 31 ++++- apps/desktop/src/renderer/src/lib/use-chat.ts | 19 ++- 13 files changed, 534 insertions(+), 69 deletions(-) create mode 100644 apps/desktop/e2e/live-onboarding.spec.ts create mode 100644 apps/desktop/src/main/config-store.ts create mode 100644 apps/desktop/src/renderer/src/components/screens/connect-screen.tsx diff --git a/apps/desktop/e2e/live-onboarding.spec.ts b/apps/desktop/e2e/live-onboarding.spec.ts new file mode 100644 index 0000000..410c1c2 --- /dev/null +++ b/apps/desktop/e2e/live-onboarding.spec.ts @@ -0,0 +1,56 @@ +import { dirname, join } from 'node:path' +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' + +import { _electron as electron, expect, test } from '@playwright/test' + +const here = dirname(fileURLToPath(import.meta.url)) +const MAIN = join(here, '..', 'out', 'main', 'index.js') + +/** Read the real token from .env.local (walking up) so the test can fill the form. */ +function readToken(): string | null { + let dir = here + for (let i = 0; i < 6; i++) { + try { + const m = /^GITEA_TOKEN\s*=\s*(.+?)\s*$/m.exec(readFileSync(join(dir, '.env.local'), 'utf8')) + if (m) return m[1].trim() + } catch { + /* keep walking */ + } + dir = dirname(dir) + } + return null +} + +// Opt-in (GITEA_LIVE=1). Forces the onboarding path (COMMITEA_NO_ENV_LOCAL) with a +// fresh userData, fills the real connection form, and asserts it lands on real data. +test.describe('live onboarding', () => { + test('connect form → real board', async () => { + const token = process.env.GITEA_LIVE ? readToken() : null + test.skip(!token, 'GITEA_LIVE + a .env.local token required') + test.setTimeout(90_000) + + const app = await electron.launch({ + args: [MAIN], + env: { ...process.env, COMMITEA_NO_ENV_LOCAL: '1', COMMITEA_USERDATA: mkdtempSync(join(tmpdir(), 'commitea-')) }, + }) + const win = await app.firstWindow() + await win.waitForLoadState('domcontentloaded') + + // the connection gate, not the app + await expect(win.getByText(/Connect your Gitea/)).toBeVisible({ timeout: 15000 }) + await win.getByPlaceholder('your-org').fill('christian') + await win.getByPlaceholder('your-repo').fill('commitea') + await win.getByPlaceholder(/gitea PAT/).fill(token!) + await win.getByRole('button', { name: 'Connect' }).click() + + // it validated + saved + reconciled into the real board — real issue titles prove it + const rail = win.getByRole('navigation', { name: 'Primary' }) + await rail.getByRole('button', { name: 'The pot' }).click() + await expect(win.getByText(/Model router|SQLite cache bootstrap|Purity\/rebuild/).first()).toBeVisible({ timeout: 30000 }) + await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-onboarding.png'), fullPage: true, animations: 'disabled' }) + + await app.close() + }) +}) diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts index eff9879..07c5828 100644 --- a/apps/desktop/e2e/smoke.spec.ts +++ b/apps/desktop/e2e/smoke.spec.ts @@ -97,7 +97,7 @@ test('Directives: consequence diff + append-only ledger', async ({ app, window } test('Settings: connection, schema, and appearance sync with theme', async ({ app, window }) => { await app.nav('Settings').click() await expect(window.getByRole('heading', { name: 'Settings' })).toBeVisible() - await expect(window.getByText('Managed repos')).toBeVisible() + await expect(window.getByRole('heading', { name: 'Connection' })).toBeVisible() await app.screenshot('settings') // the Evening (dark) radio drives the shared theme await window.getByText('Evening (dark)').click() diff --git a/apps/desktop/src/main/config-store.ts b/apps/desktop/src/main/config-store.ts new file mode 100644 index 0000000..a75f7e1 --- /dev/null +++ b/apps/desktop/src/main/config-store.ts @@ -0,0 +1,101 @@ +/** + * Persistent app config for a shared/team build. The gitea PAT is encrypted at + * rest with Electron's safeStorage (OS keychain-backed) and only ever lives in + * the main process — the renderer receives everything *except* the token. Config + * lives in userData so each teammate has their own; a `.env.local` remains a dev + * fallback (see resolveConfig). Nothing here runs under COMMITEA_E2E. + */ + +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { app, safeStorage } from 'electron' + +export interface AppConfig { + baseUrl: string + owner: string + repo: string + token: string + /** Defaults to `${repo}-pm-state` when omitted. */ + pmStateRepo?: string + /** OpenAI-compatible base for Reginald; chat stays off when absent. */ + modelUrl?: string +} + +/** The renderer-safe view — everything but the token, plus whether one is set. */ +export type PublicConfig = Omit & { hasToken: boolean } + +interface StoredConfig { + baseUrl: string + owner: string + repo: string + tokenEnc: string | null // base64 of safeStorage-encrypted token + pmStateRepo?: string + modelUrl?: string +} + +function configPath(): string { + return join(app.getPath('userData'), 'commitea-config.json') +} + +function readStored(): StoredConfig | null { + try { + return JSON.parse(readFileSync(configPath(), 'utf8')) as StoredConfig + } catch { + return null + } +} + +/** Full config incl. the decrypted token, or null when unset. Main-process only. */ +export function loadConfig(): AppConfig | null { + const s = readStored() + if (!s || !s.baseUrl || !s.owner || !s.repo) return null + let token = '' + if (s.tokenEnc) { + try { + token = safeStorage.decryptString(Buffer.from(s.tokenEnc, 'base64')) + } catch { + token = '' // key rotated / different machine — treat as no token + } + } + if (!token) return null + return { baseUrl: s.baseUrl, owner: s.owner, repo: s.repo, token, pmStateRepo: s.pmStateRepo, modelUrl: s.modelUrl } +} + +/** The renderer-safe view of the saved config (never includes the token). */ +export function publicConfig(): PublicConfig | null { + const s = readStored() + if (!s) return null + return { + baseUrl: s.baseUrl, + owner: s.owner, + repo: s.repo, + pmStateRepo: s.pmStateRepo, + modelUrl: s.modelUrl, + hasToken: !!s.tokenEnc, + } +} + +/** Encrypt the token + persist. Best-effort; throws only on a genuine write failure. */ +export function saveConfig(cfg: AppConfig): void { + const tokenEnc = cfg.token + ? Buffer.from(safeStorage.encryptString(cfg.token)).toString('base64') + : (readStored()?.tokenEnc ?? null) // keep the existing token if none supplied + const stored: StoredConfig = { + baseUrl: cfg.baseUrl.trim().replace(/\/+$/, ''), + owner: cfg.owner.trim(), + repo: cfg.repo.trim(), + tokenEnc, + pmStateRepo: cfg.pmStateRepo?.trim() || undefined, + modelUrl: cfg.modelUrl?.trim() || undefined, + } + writeFileSync(configPath(), JSON.stringify(stored, null, 2), 'utf8') +} + +export function clearConfig(): void { + try { + writeFileSync(configPath(), JSON.stringify({ baseUrl: '', owner: '', repo: '', tokenEnc: null }), 'utf8') + } catch { + // ignore + } +} diff --git a/apps/desktop/src/main/gitea.ts b/apps/desktop/src/main/gitea.ts index 032e98e..8334d54 100644 --- a/apps/desktop/src/main/gitea.ts +++ b/apps/desktop/src/main/gitea.ts @@ -13,6 +13,7 @@ import { dirname, join } from 'node:path' import { appendDirective, createGiteaClient, + GiteaApiError, type DirectiveEntry, type GiteaClient, type GiteaConfig, @@ -28,6 +29,7 @@ import { } from '@commitea/core' import { ipcMain } from 'electron' +import { type AppConfig, clearConfig, loadConfig, publicConfig, saveConfig } from './config-store.js' import { loadSnapshot, saveSnapshot } from './snapshot-store.js' /** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */ @@ -48,9 +50,15 @@ function loadEnvLocalToken(): string | undefined { return undefined } -function resolveConfig(): GiteaConfig | null { +/** Saved config wins (team build); a `.env.local` is the dev fallback; e2e uses fixtures. */ +export function resolveConfig(): GiteaConfig | null { // E2E runs against fixtures — never hit the network from the test harness. if (process.env.COMMITEA_E2E === '1') return null + const saved = loadConfig() + if (saved) return { baseUrl: saved.baseUrl, token: saved.token, owner: saved.owner, repo: saved.repo } + // COMMITEA_NO_ENV_LOCAL forces the onboarding path (no .env.local fallback) for testing. + if (process.env.COMMITEA_NO_ENV_LOCAL === '1') return null + // dev fallback — .env.local / env vars const token = process.env.GITEA_TOKEN ?? loadEnvLocalToken() if (!token) return null return { @@ -61,8 +69,23 @@ function resolveConfig(): GiteaConfig | null { } } -// Memoized client so both the gitea and model bridges share one instance. +/** The pm-state repo name: configured, else `${repo}-pm-state`, else the env override. */ +function pmStateRepoName(config: GiteaConfig): string { + return loadConfig()?.pmStateRepo ?? process.env.COMMITEA_PMSTATE_REPO ?? `${config.repo}-pm-state` +} + +// Memoized clients; reset via resetClients() when the config changes. let sharedClient: GiteaClient | null | undefined +let pmStateClient: GiteaClient | null | undefined + +/** Drop the cached clients + snapshot so the next call re-reads the new config. */ +export function resetClients(): void { + sharedClient = undefined + pmStateClient = undefined + labelCache = null + invalidateSnapshot() +} + export function getGiteaClient(): GiteaClient | null { if (sharedClient === undefined) { const config = resolveConfig() @@ -73,13 +96,10 @@ export function getGiteaClient(): GiteaClient | null { // The pm-state repo holds machine-derived state (the directive ledger). Same // token/host as the work repo, a different repo (the purity split, decisions D4). -let pmStateClient: GiteaClient | null | undefined export function getPmStateClient(): GiteaClient | null { if (pmStateClient === undefined) { const config = resolveConfig() - pmStateClient = config - ? createGiteaClient({ ...config, repo: process.env.COMMITEA_PMSTATE_REPO ?? 'commitea-pm-state' }, fetch) - : null + pmStateClient = config ? createGiteaClient({ ...config, repo: pmStateRepoName(config) }, fetch) : null } return pmStateClient } @@ -192,20 +212,38 @@ export function bootSnapshot(): (Snapshot & { savedAt: string }) | null { /** Agent tool calls tolerate a slightly stale snapshot (seconds) to stay responsive. */ export const AGENT_SNAPSHOT_TTL_MS = 30_000 -export function registerGiteaIpc(): void { - const client = getGiteaClient() - const repo = client ? `${process.env.GITEA_OWNER ?? 'christian'}/${process.env.GITEA_REPO ?? 'commitea'}` : null +// Label cache is reset with the clients (module-level so resetClients can clear it). +let labelCache: GiteaLabel[] | null = null +async function resolveLabelIds(client: GiteaClient, names: string[]): Promise { + const lookup = () => new Map(labelCache!.map((l) => [l.name, l.id])) + if (!labelCache) labelCache = await client.listLabels() + let byName = lookup() + if (names.some((n) => !byName.has(n))) { + labelCache = await client.listLabels() // a name we don't know — refetch once + byName = lookup() + } + return names.map((n) => byName.get(n)).filter((id): id is number => id != null) +} - ipcMain.handle('gitea:status', () => ({ configured: !!client, repo })) +export function registerGiteaIpc(): void { + ipcMain.handle('gitea:status', () => { + const cfg = resolveConfig() + return { + configured: !!cfg, + repo: cfg ? `${cfg.owner}/${cfg.repo}` : null, + demo: process.env.COMMITEA_E2E === '1', + } + }) // Instant boot: the last persisted snapshot, shown before the fresh reconcile lands. ipcMain.handle('gitea:boot', () => { - if (!client) return { configured: false } + if (!getGiteaClient()) return { configured: false } const persisted = bootSnapshot() return persisted ? { configured: true, cached: true, ...persisted } : { configured: true, cached: false } }) ipcMain.handle('gitea:reconcile', async () => { + const client = getGiteaClient() if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} } try { // explicit UI sync — force fresh, and warm the cache for agent tool calls @@ -220,32 +258,20 @@ export function registerGiteaIpc(): void { }) ipcMain.handle('gitea:getIssue', async (_event, index: number) => { + const client = getGiteaClient() if (!client) return null return client.getIssue(index) }) - // Cached label list for name→id resolution; refreshed on demand if a name misses. - let labelCache: GiteaLabel[] | null = null - async function resolveLabelIds(names: string[]): Promise { - if (!client) return [] - const lookup = () => new Map(labelCache!.map((l) => [l.name, l.id])) - if (!labelCache) labelCache = await client.listLabels() - let byName = lookup() - if (names.some((n) => !byName.has(n))) { - labelCache = await client.listLabels() // a name we don't know — refetch once - byName = lookup() - } - return names.map((n) => byName.get(n)).filter((id): id is number => id != null) - } - // The write path (apply_changes). Additive label swaps, applied only after the // renderer's propose-approve. Returns the plan + the freshly-read issue. ipcMain.handle('gitea:applyChange', async (_event, change: IssueChange) => { + const client = getGiteaClient() if (!client) return { ok: false as const, reason: 'unconfigured' as const } const current = await client.getIssue(change.issue) const plan = planIssueChange(current.labels, change) if (plan.noop) return { ok: true as const, plan, issue: current } - const ids = await resolveLabelIds(plan.labels) + const ids = await resolveLabelIds(client, plan.labels) await client.setIssueLabels(change.issue, ids) const issue = await client.getIssue(change.issue) invalidateSnapshot() // the board + forecast must reflect the label change @@ -257,11 +283,12 @@ export function registerGiteaIpc(): void { ipcMain.handle( 'gitea:createIssues', async (_event, issues: { title: string; body?: string; estimate?: string; priority?: string }[]) => { + const client = getGiteaClient() if (!client) return { ok: false as const, reason: 'unconfigured' as const } const created: { number: number; title: string }[] = [] for (const it of issues) { const names = [it.estimate, it.priority].filter((n): n is string => !!n) - const labelIds = await resolveLabelIds(names) + const labelIds = await resolveLabelIds(client, names) const issue = await client.createIssue({ title: it.title, body: it.body, labelIds }) created.push({ number: issue.number, title: issue.title }) } @@ -291,4 +318,33 @@ export function registerGiteaIpc(): void { return { ok: true as const, members: [] } } }) + + // ---- config (team onboarding) ---- + ipcMain.handle('config:get', () => publicConfig()) + + ipcMain.handle('config:set', (_event, cfg: AppConfig) => { + saveConfig(cfg) + resetClients() // new config takes effect without a restart + return { ok: true as const } + }) + + ipcMain.handle('config:clear', () => { + clearConfig() + resetClients() + return { ok: true as const } + }) + + // Validate a token + repo before saving: any authed read on the repo proves access. + ipcMain.handle('config:test', async (_event, cfg: AppConfig) => { + try { + const c = createGiteaClient( + { baseUrl: cfg.baseUrl.replace(/\/+$/, ''), owner: cfg.owner.trim(), repo: cfg.repo.trim(), token: cfg.token }, + fetch, + ) + await c.listLabels() + return { ok: true as const } + } catch (e) { + return { ok: false as const, error: e instanceof GiteaApiError ? `${e.status}` : e instanceof Error ? e.message : String(e) } + } + }) } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index bf09116..5c32f20 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -2,6 +2,9 @@ import { join } from 'node:path' import { BrowserWindow, app, shell } from 'electron' +// Test hook: isolate config/cache to a throwaway dir (must run before any getPath). +if (process.env.COMMITEA_USERDATA) app.setPath('userData', process.env.COMMITEA_USERDATA) + import { registerGiteaIpc } from './gitea.js' import { registerModelIpc } from './model.js' diff --git a/apps/desktop/src/main/model.ts b/apps/desktop/src/main/model.ts index 416c905..a4c7711 100644 --- a/apps/desktop/src/main/model.ts +++ b/apps/desktop/src/main/model.ts @@ -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 { + 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 { - 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) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 0215f37..cd8c2c9 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -22,6 +22,16 @@ const api = { /** Read the capacity config from the pm-state repo. */ capacity: () => ipcRenderer.invoke('pmstate:capacity'), }, + config: { + /** The saved config (never the token) — null when unset. */ + get: () => ipcRenderer.invoke('config:get'), + /** Validate a token + repo before saving. */ + test: (cfg: unknown) => ipcRenderer.invoke('config:test', cfg), + /** Save config (token encrypted in main); takes effect without restart. */ + set: (cfg: unknown) => ipcRenderer.invoke('config:set', cfg), + /** Forget the saved config. */ + clear: () => ipcRenderer.invoke('config:clear'), + }, model: { /** Whether a model endpoint is configured (else the UI keeps the scripted Reginald). */ status: () => ipcRenderer.invoke('model:status'), diff --git a/apps/desktop/src/renderer/src/components/screens/connect-screen.tsx b/apps/desktop/src/renderer/src/components/screens/connect-screen.tsx new file mode 100644 index 0000000..2ba7f25 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/screens/connect-screen.tsx @@ -0,0 +1,117 @@ +import React from 'react' + +import type { PublicConfig } from '../../global.js' +import logoIcon from '../../design/assets/logo-icon.png' +import { Button, Card, Input } from '../ui/index.js' + +/** + * Real onboarding — each teammate connects their own Gitea with a personal access + * token. The token is validated (an authed read on the repo) then saved encrypted + * in the main process (never exposed to this renderer). Optional model URL wires + * Reginald; leave it blank and chat simply stays off. + */ +export function ConnectScreen({ onConnected, existing }: { onConnected: () => void; existing?: PublicConfig | null }) { + const [baseUrl, setBaseUrl] = React.useState(existing?.baseUrl ?? 'https://gitea.stephenmann.io') + const [owner, setOwner] = React.useState(existing?.owner ?? '') + const [repo, setRepo] = React.useState(existing?.repo ?? '') + const [token, setToken] = React.useState('') + const [modelUrl, setModelUrl] = React.useState(existing?.modelUrl ?? '') + const [state, setState] = React.useState<'idle' | 'testing' | 'saving'>('idle') + const [error, setError] = React.useState(null) + + const cfg = () => ({ baseUrl, owner, repo, token, modelUrl: modelUrl.trim() || undefined }) + const ready = baseUrl.trim() && owner.trim() && repo.trim() && (token.trim() || existing?.hasToken) + + const connect = async () => { + setError(null) + setState('testing') + // reuse the saved token when editing and the field is left blank + const payload = token.trim() ? cfg() : { ...cfg(), token: '' } + if (token.trim()) { + const test = await window.commitea.config.test(cfg()).catch(() => ({ ok: false, error: 'unreachable' })) + if (!test.ok) { + setState('idle') + setError( + test.error === '404' + ? "Couldn't find that repo — check owner/name and that the token can see it." + : test.error === '401' || test.error === '403' + ? 'The token was rejected — check it has repo + issue scopes.' + : `Connection failed${test.error ? ` (${test.error})` : ''}.`, + ) + return + } + } + setState('saving') + await window.commitea.config.set(payload) + onConnected() + } + + const field = ( + label: string, + value: string, + set: (v: string) => void, + opts: { placeholder?: string; type?: string; hint?: string } = {}, + ) => ( +
+ set(e.target.value)} + /> + {opts.hint ? {opts.hint} : null} +
+ ) + + return ( +
+ +
+ + + CommiTea + +
+

+ Connect your Gitea. Your token is stored encrypted on this machine and never leaves it. +

+ +
+ {field('Gitea URL', baseUrl, setBaseUrl, { placeholder: 'https://gitea.example.com' })} +
+ {field('Owner', owner, setOwner, { placeholder: 'your-org' })} + {field('Repository', repo, setRepo, { placeholder: 'your-repo' })} +
+ {field('Access token', token, setToken, { + type: 'password', + placeholder: existing?.hasToken ? '•••••••• (saved — leave blank to keep)' : 'gitea PAT · scopes: repo + issue', + })} + {field('Model URL', modelUrl, setModelUrl, { + placeholder: 'http://localhost:1234/v1', + hint: 'Optional — an OpenAI-compatible endpoint for Reginald. Leave blank to keep chat off.', + })} +
+ + {error ? ( +

{error}

+ ) : null} + +
+ +
+
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/screens/settings-screen.tsx b/apps/desktop/src/renderer/src/components/screens/settings-screen.tsx index 377983a..b0a1545 100644 --- a/apps/desktop/src/renderer/src/components/screens/settings-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/settings-screen.tsx @@ -1,9 +1,22 @@ import React from 'react' +import type { PublicConfig } from '../../global.js' import { Badge, Button, Card, Icon, IconButton, Input, Radio, Select, Switch, Tag } from '../ui/index.js' // Settings — gitea connection, sync, model roles, labels, rituals, appearance -export function SettingsScreen({ dark, setDark }: { dark: boolean; setDark: (v: boolean) => void }) { +export function SettingsScreen({ + dark, + setDark, + connection, + onReconnect, + onDisconnect, +}: { + dark: boolean + setDark: (v: boolean) => void + connection?: PublicConfig | null + onReconnect?: () => void + onDisconnect?: () => void +}) { const [webhooks, setWebhooks] = React.useState(true) const [reconcile, setReconcile] = React.useState(true) const [poll, setPoll] = React.useState(true) @@ -24,25 +37,35 @@ export function SettingsScreen({ dark, setDark }: { dark: boolean; setDark: (v: -
- - -
- Managed repos - - - stephen/commitea - syncing - +
+ {connection ? ( + <> + + + {connection.owner}/{connection.repo} + connected + + + {connection.baseUrl} + + + sidecar: {connection.pmStateRepo ?? `${connection.repo}-pm-state`} + + + model: {connection.modelUrl ?? 'not set — chat off'} + + Your token is stored encrypted on this machine. Delete the sidecar and resync — no truth is lost. + + + + + + ) : ( + + Not connected. + - - - stephen/pm-state - sidecar - - The sidecar holds machine-derived state only. Delete it and resync — no truth is lost. - -
+ )}
diff --git a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx index 315ce20..c7e8187 100644 --- a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx +++ b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx @@ -3,7 +3,9 @@ import React, { useEffect, useState } from 'react' import logoIcon from '../../design/assets/logo-icon.png' import type { IssueChange } from '@commitea/core' +import type { PublicConfig } from '../../global.js' import type { IssueRef } from '../../data/fixtures.js' +import { ConnectScreen } from '../screens/connect-screen.js' import { backlogCalibration, capacityWorkers, @@ -97,6 +99,8 @@ export function AppShell() { const [issue, setIssue] = useState(null) const [readIds, setReadIds] = useState([]) const [milestoneId, setMilestoneId] = useState(null) + const [gate, setGate] = useState<'checking' | 'connect' | 'ready'>('checking') + const [pubConfig, setPubConfig] = useState(null) const [backlog, refetchBacklog] = useBacklog() const capacityMembers = useCapacity() const workers = capacityWorkers(capacityMembers) @@ -132,6 +136,16 @@ export function AppShell() { document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') }, [dark]) + // Gate: e2e/demo → the shell (fixtures); configured → the shell (real data); + // otherwise → the connect screen (each teammate brings their own token). + useEffect(() => { + window.commitea.gitea + .status() + .then((s) => setGate(s.demo || s.configured ? 'ready' : 'connect')) + .catch(() => setGate('connect')) + window.commitea.config.get().then(setPubConfig).catch(() => {}) + }, []) + const openIssue = (ref: IssueRef) => { if (view !== 'issue') setPrevView(view) setIssue(ref) @@ -250,7 +264,20 @@ export function AppShell() { case 'directives': return case 'settings': - return + return ( + setGate('connect')} + onDisconnect={() => { + void window.commitea.config.clear().then(() => { + setPubConfig(null) + setGate('connect') + }) + }} + /> + ) case 'issue': return issue ? ( + } + if (gate === 'connect') { + return ( + { + window.commitea.config.get().then(setPubConfig).catch(() => {}) + setGate('ready') + refetchBacklog() + }} + /> + ) + } + // First run is full-window — no rail, no chat panel if (view === 'firstrun') { return setView(dest)} /> diff --git a/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx b/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx index 082f7e4..d396d13 100644 --- a/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx +++ b/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx @@ -62,7 +62,7 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane Reginald - {offline ? 'offline · queueing' : live ? `${modelLabel} · local` : 'demo · scripted'} + {offline ? 'offline · queueing' : live ? `${modelLabel} · local` : 'no model'} @@ -161,8 +161,8 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane send() } }} - placeholder={offline ? 'Writes wait for the connection…' : 'Tell me what to do…'} - disabled={offline} + placeholder={offline ? 'Writes wait for the connection…' : live ? 'Tell me what to do…' : 'Add a Model URL in Settings…'} + disabled={offline || !live} rows={1} style={{ flex: 1, @@ -180,15 +180,15 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane type="button" onClick={send} aria-label="Send" - disabled={offline} + disabled={offline || !live} style={{ width: 30, height: 30, borderRadius: 'var(--radius-2)', border: 'none', - cursor: offline ? 'not-allowed' : 'pointer', - background: offline ? 'var(--paper-3)' : 'var(--accent)', - color: offline ? 'var(--ink-3)' : 'var(--ink-inverse)', + cursor: offline || !live ? 'not-allowed' : 'pointer', + background: offline || !live ? 'var(--paper-3)' : 'var(--accent)', + color: offline || !live ? 'var(--ink-3)' : 'var(--ink-inverse)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', diff --git a/apps/desktop/src/renderer/src/global.d.ts b/apps/desktop/src/renderer/src/global.d.ts index 0ff0861..d473b52 100644 --- a/apps/desktop/src/renderer/src/global.d.ts +++ b/apps/desktop/src/renderer/src/global.d.ts @@ -49,9 +49,37 @@ export type BootPayload = | { configured: true; cached: false } | ({ configured: true; cached: true } & Omit) +/** The saved connection config (never includes the token). */ +export interface PublicConfig { + baseUrl: string + owner: string + repo: string + pmStateRepo?: string + modelUrl?: string + hasToken: boolean +} + +/** What the onboarding/settings form submits. */ +export interface ConfigInput { + baseUrl: string + owner: string + repo: string + token: string + pmStateRepo?: string + modelUrl?: string +} + +/** The config bridge for team onboarding. */ +export interface ConfigBridge { + get(): Promise + test(cfg: ConfigInput): Promise<{ ok: boolean; error?: string }> + set(cfg: ConfigInput): Promise<{ ok: boolean }> + clear(): Promise<{ ok: boolean }> +} + /** The gitea bridge exposed by the preload over IPC (main-process backed). */ export interface GiteaBridge { - status(): Promise<{ configured: boolean; repo: string | null }> + status(): Promise<{ configured: boolean; repo: string | null; demo: boolean }> boot(): Promise reconcile(): Promise getIssue(index: number): Promise @@ -96,6 +124,7 @@ declare global { gitea: GiteaBridge model: ModelBridge pmstate: PmStateBridge + config: ConfigBridge } } } diff --git a/apps/desktop/src/renderer/src/lib/use-chat.ts b/apps/desktop/src/renderer/src/lib/use-chat.ts index a0eb33b..dec3353 100644 --- a/apps/desktop/src/renderer/src/lib/use-chat.ts +++ b/apps/desktop/src/renderer/src/lib/use-chat.ts @@ -2,13 +2,18 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { ChangeProposal, ChatMessage as WireMessage, IssueChange } from '@commitea/core' -import { CANNED_REPLY, CHAT, type ChatMessage } from '../data/fixtures.js' +import { CHAT, type ChatMessage } from '../data/fixtures.js' const LIVE_GREETING: ChatMessage = { from: 'agent', text: 'Morning. Ask me anything about the project — I check the real board before I answer.', } +const NO_MODEL: ChatMessage = { + from: 'agent', + text: "Reginald is off — I need a model. Add a Model URL in Settings and chat turns on.", +} + export interface ChatState { msgs: ChatMessage[] thinking: boolean @@ -55,10 +60,14 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b window.commitea.model .status() .then((s) => { - if (alive && s.configured) { + if (!alive) return + if (s.configured) { setLive(true) setModel(s.model) setSeed([LIVE_GREETING]) + } else { + // no model → clean "off" state, not a scripted fixture reply + setSeed([NO_MODEL]) } }) .catch(() => {}) @@ -78,10 +87,8 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b setProposals([]) if (!live) { - window.setTimeout(() => { - setThinking(false) - setConvo((c) => [...c, { from: 'agent', text: CANNED_REPLY }]) - }, 900) + // no model configured — nothing to send (composer is disabled anyway) + setThinking(false) return } -- 2.49.1