Compare commits

...

3 Commits

Author SHA1 Message Date
8bed2f75c1 Merge pull request 'Dogfooding fixes: empty-state gaps + Settings/Capture mocks the sweep missed' (#62) from fix/empty-focus-and-calm-nag into main
Reviewed-on: #62
2026-07-12 02:03:43 +00:00
Croissant Le Doux
1026c762a9 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>
2026-07-10 16:09:59 -04:00
Croissant Le Doux
50db790f86 Fix blank Focus + calm-standup nag chrome (found dogfooding live)
Poking at the live app (a real repo with no open issues) surfaced two empty-state gaps:

- Focus (Morning service) rendered a bare header — no cards, no empty state —
  because scheduleFocus returns a FocusView with empty now/next/later rather than
  undefined, so the `if (!focus)` guard never fired. Guard on 'no now/next/later'
  too → shows the 'Nothing to pour' EmptyState.
- Standup's calm sentinel nag (id 0) still rendered '#0 · steeping 0d · blocks'
  chrome and was a click target (would try to open issue #0). Render the calm
  case as a plain reassurance pill, no chrome, not clickable.

Both verified against the live christian/commitea repo (all issues closed → no
open work). desktop tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:57:30 -04:00
5 changed files with 165 additions and 120 deletions

View File

@@ -365,7 +365,23 @@ export function registerGiteaIpc(): void {
}) })
// ---- config (team onboarding) ---- // ---- 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) => { ipcMain.handle('config:set', (_event, cfg: AppConfig) => {
saveConfig(cfg) saveConfig(cfg)

View File

@@ -24,10 +24,7 @@ interface Question {
export function CaptureScreen({ onDone }: { onDone: () => void }) { export function CaptureScreen({ onDone }: { onDone: () => void }) {
const [stage, setStage] = React.useState<'dump' | 'interview' | 'review' | 'filed'>('dump') const [stage, setStage] = React.useState<'dump' | 'interview' | 'review' | 'filed'>('dump')
const [dump, setDump] = React.useState( 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 [qi, setQi] = React.useState(0) const [qi, setQi] = React.useState(0)
const [log, setLog] = React.useState<{ q: string; a: string }[]>([]) const [log, setLog] = React.useState<{ q: string; a: string }[]>([])
const [split, setSplit] = React.useState<boolean | null>(null) const [split, setSplit] = React.useState<boolean | null>(null)

View File

@@ -7,9 +7,10 @@ import { EmptyState } from '../shell/states.js'
import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js' import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js'
/** /**
* Morning service — the Now/Next/Later focus cards + the burn-up cone. * Morning service — the Now/Next/Later focus cards + the burn-up cone, from the
* `focus` (scheduler) and `forecast` (Monte Carlo) override the demo fixtures * scheduler (`focus`) and Monte Carlo forecast (`forecast`). Both come from the
* when gitea is configured; both fall back to the handoff demo otherwise. * reconciled backlog; with no open work the scheduler has nothing to pour, so we
* show the empty state.
*/ */
export function FocusScreen({ export function FocusScreen({
onOpenIssue, onOpenIssue,
@@ -66,7 +67,9 @@ export function FocusScreen({
</Card> </Card>
) )
if (!focus) { // The scheduler returns a FocusView even when the backlog has no open work —
// treat "no now/next/later" as empty, not just an absent `focus`.
if (!focus || (!focus.now && !focus.next && !focus.later)) {
return ( return (
<EmptyState <EmptyState
icon="coffee" icon="coffee"

View File

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

View File

@@ -161,39 +161,59 @@ export function StandupScreen({
</Section> </Section>
<Section overline="Stale blockers" order={3}> <Section overline="Stale blockers" order={3}>
<div {s.nag.id === 0 ? (
role="button" // Calm sentinel — nothing is over-steeping; render just the reassurance,
tabIndex={0} // no issue chrome, no click target (there's no #0 to open).
onClick={() => onOpenIssue(NAG_REF)} <div
onKeyDown={(e) => { style={{
if (e.key === 'Enter') onOpenIssue(NAG_REF) display: 'flex',
}} gap: 10,
style={{ alignItems: 'center',
display: 'flex', background: 'var(--paper-2)',
gap: 10, borderRadius: 'var(--radius-2)',
alignItems: 'flex-start', padding: '12px 14px',
cursor: 'pointer', }}
background: 'var(--warn-tint)', >
borderRadius: 'var(--radius-2)', <span style={{ color: 'var(--ok)', display: 'inline-flex' }}>
padding: '12px 14px', <Icon name="circle-check" size={15} />
}} </span>
> <p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>{s.nag.text}</p>
<span style={{ color: 'var(--warn)', display: 'inline-flex', marginTop: 2 }}>
<Icon name="clock" size={15} />
</span>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ font: '500 12.5px var(--font-mono)', color: 'var(--ink-1)' }}>#{s.nag.id}</span>
<Badge tone="warn" dot>
steeping {s.nag.days}
</Badge>
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)' }}>
blocks {s.nag.blocks.join(', ')}
</span>
</div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '5px 0 0' }}>{s.nag.text}</p>
</div> </div>
</div> ) : (
<div
role="button"
tabIndex={0}
onClick={() => onOpenIssue(NAG_REF)}
onKeyDown={(e) => {
if (e.key === 'Enter') onOpenIssue(NAG_REF)
}}
style={{
display: 'flex',
gap: 10,
alignItems: 'flex-start',
cursor: 'pointer',
background: 'var(--warn-tint)',
borderRadius: 'var(--radius-2)',
padding: '12px 14px',
}}
>
<span style={{ color: 'var(--warn)', display: 'inline-flex', marginTop: 2 }}>
<Icon name="clock" size={15} />
</span>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ font: '500 12.5px var(--font-mono)', color: 'var(--ink-1)' }}>#{s.nag.id}</span>
<Badge tone="warn" dot>
steeping {s.nag.days}
</Badge>
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)' }}>
blocks {s.nag.blocks.join(', ')}
</span>
</div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '5px 0 0' }}>{s.nag.text}</p>
</div>
</div>
)}
</Section> </Section>
<footer <footer