De-mock Settings + empty Capture braindump + resolve connection status

More static mocks found by poking the live app:

- Settings was ~80% mock: a fake Sync card (webhook endpoint :48731, poll
  fallback, 'last reconcile 3.2s · 500 issues'), a fake dual-model 'router'
  (gemma-4b/qwen-72b with hardcoded URLs), and an unwired Rituals card. Replaced
  with honest/live content: the Sync card now describes the real on-demand
  reconcile + stale-cache model; the Model card shows the actual probed model
  from model.status(); dropped Rituals and the duplicate 'Forget' button.
- Settings showed 'Not connected' while the app was clearly connected: config:get
  returned only the *saved* config, so a .env.local/env connection read as null.
  It now falls back to a public view of the resolved connection — Settings and
  the rail host label reflect the real christian/commitea connection.
- Capture braindump was pre-filled with a fake example ('auth is flaky…'); now
  starts empty with just the placeholder.

Verified live: Settings shows christian/commitea connected + google/gemma-4-26b
reachable; Capture empty; a live Reginald chat turn runs the agent loop.
desktop tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Croissant Le Doux
2026-07-10 16:09:59 -04:00
parent 50db790f86
commit 1026c762a9
3 changed files with 106 additions and 84 deletions

View File

@@ -365,7 +365,23 @@ export function registerGiteaIpc(): void {
})
// ---- config (team onboarding) ----
ipcMain.handle('config:get', () => publicConfig())
// The saved config's public view, or — when running off a .env.local / env
// fallback (dev) — a public view of the *resolved* connection, so Settings and
// the rail reflect what the app is actually connected to, not just the store.
ipcMain.handle('config:get', () => {
const saved = publicConfig()
if (saved) return saved
const c = resolveConfig()
if (!c) return null
return {
baseUrl: c.baseUrl,
owner: c.owner,
repo: c.repo,
pmStateRepo: pmStateRepoName(c),
modelUrl: process.env.MODEL_BASE_URL ?? undefined,
hasToken: !!c.token,
}
})
ipcMain.handle('config:set', (_event, cfg: AppConfig) => {
saveConfig(cfg)

View File

@@ -24,10 +24,7 @@ interface Question {
export function CaptureScreen({ onDone }: { onDone: () => void }) {
const [stage, setStage] = React.useState<'dump' | 'interview' | 'review' | 'filed'>('dump')
const [dump, setDump] = React.useState(
'auth is flaky — token refresh dies silently, sometimes session storage goes stale. ' +
'also the webhook debounce thing keeps double-firing. and we owe docs for auth setup'
)
const [dump, setDump] = React.useState('')
const [qi, setQi] = React.useState(0)
const [log, setLog] = React.useState<{ q: string; a: string }[]>([])
const [split, setSplit] = React.useState<boolean | null>(null)

View File

@@ -1,9 +1,9 @@
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'
import { Badge, Button, Card, Icon, Radio, Tag } from '../ui/index.js'
// Settings — gitea connection, sync, model roles, labels, rituals, appearance
// Settings — gitea connection, sync behaviour, model, label schema, appearance.
export function SettingsScreen({
dark,
setDark,
@@ -17,10 +17,20 @@ export function SettingsScreen({
onReconnect?: () => void
onDisconnect?: () => void
}) {
const [webhooks, setWebhooks] = React.useState(true)
const [reconcile, setReconcile] = React.useState(true)
const [poll, setPoll] = React.useState(true)
const [nag, setNag] = React.useState(true)
const [model, setModel] = React.useState<{ configured: boolean; model: string | null } | null>(null)
React.useEffect(() => {
let alive = true
window.commitea.model
.status()
.then((s) => {
if (alive) setModel(s)
})
.catch(() => {})
return () => {
alive = false
}
}, [])
const Row = ({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, ...style }}>{children}</div>
@@ -33,128 +43,127 @@ export function SettingsScreen({
<div style={{ maxWidth: 720, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: 16 }}>
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Settings</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>config lives in pm-state · versioned, portable</p>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>
connection lives on this machine · the plan lives in gitea
</p>
</header>
<Card overline="Gitea" title="Connection">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{connection ? (
<>
<Row style={{ padding: '8px 12px', background: 'var(--paper-0)', border: '1px solid var(--line-1)', borderRadius: 'var(--radius-2)' }}>
<Row
style={{
padding: '8px 12px',
background: 'var(--paper-0)',
border: '1px solid var(--line-1)',
borderRadius: 'var(--radius-2)',
}}
>
<Icon name="git-branch" size={14} style={{ color: 'var(--ink-3)' }} />
<span style={{ font: 'var(--text-data)', color: 'var(--ink-1)', flex: 1 }}>{connection.owner}/{connection.repo}</span>
<Badge tone="ok" dot>connected</Badge>
<span style={{ font: 'var(--text-data)', color: 'var(--ink-1)', flex: 1 }}>
{connection.owner}/{connection.repo}
</span>
<Badge tone="ok" dot>
connected
</Badge>
</Row>
<Row style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', gap: 6 }}>
<Icon name="link" size={12} />{connection.baseUrl}
<Icon name="link" size={12} />
{connection.baseUrl}
</Row>
<Row style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', gap: 6 }}>
<Icon name="layers" size={12} />sidecar: {connection.pmStateRepo ?? `${connection.repo}-pm-state`}
</Row>
<Row style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', gap: 6 }}>
<Icon name="sparkles" size={12} />model: {connection.modelUrl ?? 'not set — chat off'}
<Icon name="layers" size={12} />
sidecar: {connection.pmStateRepo ?? `${connection.repo}-pm-state`}
</Row>
<Note>Your token is stored encrypted on this machine. Delete the sidecar and resync no truth is lost.</Note>
<Row style={{ gap: 8, marginTop: 2 }}>
<Button variant="secondary" size="sm" icon="settings-2" onClick={onReconnect}>Reconfigure</Button>
<Button variant="ghost" size="sm" onClick={onDisconnect}>Disconnect</Button>
<Button variant="secondary" size="sm" icon="settings-2" onClick={onReconnect}>
Reconfigure
</Button>
<Button variant="ghost" size="sm" onClick={onDisconnect}>
Disconnect
</Button>
</Row>
</>
) : (
<Row style={{ gap: 8 }}>
<Note>Not connected.</Note>
<Button variant="secondary" size="sm" onClick={onReconnect}>Connect</Button>
<Button variant="secondary" size="sm" onClick={onReconnect}>
Connect
</Button>
</Row>
)}
</div>
</Card>
<Card overline="Sync" title="Staying current">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Row>
<Switch label="Webhooks while running" checked={webhooks} onChange={(e) => setWebhooks(e.target.checked)} />
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)', marginLeft: 'auto' }}>endpoint :48731 · healthy</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Row style={{ gap: 8 }}>
<Icon name="refresh-cw" size={14} style={{ color: 'var(--ink-3)' }} />
<span style={{ font: 'var(--text-body)', color: 'var(--ink-1)' }}>
Reconciles on launch and after every write
</span>
</Row>
<Switch label="Full reconcile on launch" checked={reconcile} onChange={(e) => setReconcile(e.target.checked)} />
<Row>
<Switch label="Poll fallback" checked={poll} onChange={(e) => setPoll(e.target.checked)} />
<div style={{ marginLeft: 'auto', width: 140 }}>
<Select options={[{ value: '2', label: 'every 2 min' }, { value: '5', label: 'every 5 min' }, { value: '15', label: 'every 15 min' }]} defaultValue="5" />
</div>
</Row>
<Note>last reconcile 3.2s · 500 issues · nothing lost</Note>
<Note>
The board is a rebuildable index over gitea the durable truth. When gitea is unreachable the last
snapshot is served from cache (the rail's dot turns red), and the next successful reconcile supersedes it.
No webhooks, no polling: reads are on demand.
</Note>
</div>
</Card>
<Card overline="Models" title="The router">
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px 14px' }}>
<Select label="Prose & rituals" options={[
{ value: 'gemma-4b', label: 'gemma-4b · local' },
{ value: 'qwen-7b', label: 'qwen-7b · local' },
]} defaultValue="gemma-4b" />
<Input label="Base URL" mono defaultValue="http://localhost:1234/v1" />
<Select label="Decomposition & negotiation" options={[
{ value: 'qwen-72b', label: 'qwen-72b · lm-studio box' },
{ value: 'gpt-4o', label: 'gpt-4o · OpenAI API' },
]} defaultValue="qwen-72b" />
<Input label="Base URL" mono defaultValue="http://10.0.0.42:1234/v1" />
</div>
<Row>
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)' }}>hot memory 2k tokens · math is never delegated to either</span>
<Card overline="Model" title="Reginald's brain">
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{model?.configured ? (
<Row style={{ gap: 8 }}>
<Icon name="sparkles" size={14} style={{ color: 'var(--ink-3)' }} />
<span style={{ font: 'var(--text-data)', color: 'var(--ink-1)', flex: 1 }}>{model.model}</span>
<Badge tone="ok" dot>
reachable
</Badge>
</Row>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
The small one writes my standup; the large one argues with your estimates. Neither is allowed near the arithmetic.
</p>
) : (
<Row style={{ gap: 8 }}>
<Icon name="sparkles" size={14} style={{ color: 'var(--ink-3)' }} />
<span style={{ font: 'var(--text-body)', color: 'var(--ink-2)', flex: 1 }}>
Chat is off — no model reachable.
</span>
<Button variant="secondary" size="sm" onClick={onReconnect}>
Set a Model URL
</Button>
</Row>
)}
<Note>
An OpenAI-compatible endpoint (set it in Reconfigure). Reginald names, negotiates and explains; it never
does the arithmetic — the scheduler and Monte Carlo do that.
</Note>
</div>
</Card>
<Card overline="Labels" title="Schema">
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Row style={{ flexWrap: 'wrap', gap: 6 }}>
{['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d'].map((l) => <Tag key={l} label={l} />)}
{['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d'].map((l) => (
<Tag key={l} label={l} />
))}
</Row>
<Row style={{ flexWrap: 'wrap', gap: 6 }}>
{['p/1', 'p/2', 'p/3', 'p/4'].map((l) => <Tag key={l} label={l} />)}
{['p/1', 'p/2', 'p/3', 'p/4'].map((l) => (
<Tag key={l} label={l} />
))}
<Tag label="deadline/hard" />
</Row>
<Note>Fixed sets, human-meaningful, visible in gitea. Not configurable — that is rather the point.</Note>
</div>
</Card>
<Card overline="Rituals" title="Reginald's calendar">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Row>
<span style={{ font: 'var(--text-body)', color: 'var(--ink-1)' }}>Morning standup</span>
<div style={{ marginLeft: 'auto', width: 120 }}>
<Select options={[{ value: '0630', label: '06:30' }, { value: '0700', label: '07:00' }, { value: '0800', label: '08:00' }]} defaultValue="0700" />
</div>
</Row>
<Row>
<Switch label="Stale-blocker nagging" checked={nag} onChange={(e) => setNag(e.target.checked)} />
<div style={{ marginLeft: 'auto', width: 140 }}>
<Select options={[{ value: '2', label: 'after 2 days' }, { value: '3', label: 'after 3 days' }, { value: '5', label: 'after 5 days' }]} defaultValue="3" />
</div>
</Row>
</div>
</Card>
<Card overline="Appearance" title="Service">
<div style={{ display: 'flex', gap: 20 }}>
<Radio name="theme" label="Morning (light)" checked={!dark} onChange={() => setDark(false)} />
<Radio name="theme" label="Evening (dark)" checked={dark} onChange={() => setDark(true)} />
</div>
</Card>
<Card>
<Row>
<div style={{ flex: 1 }}>
<div style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>Forget this gitea</div>
<Note>Removes the connection and the local cache. Gitea itself is untouched.</Note>
</div>
<Button variant="danger">Forget</Button>
</Row>
</Card>
</div>
)
}