Compare commits
7 Commits
feat/kill-
...
ec32c6c8a9
| Author | SHA1 | Date | |
|---|---|---|---|
| ec32c6c8a9 | |||
| 8bed2f75c1 | |||
|
|
2f4a0a114a | ||
|
|
8d750139a1 | ||
|
|
1026c762a9 | ||
|
|
50db790f86 | ||
| 1000d053b3 |
@@ -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)
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export function CalibrationScreen({ onBack, data }: { onBack: () => void; data?:
|
|||||||
<circle key={i} cx={X(e) + ((i % 5) - 2) * 3} cy={Y(a)} r="3" fill="var(--accent)" opacity="0.55" />
|
<circle key={i} cx={X(e) + ((i % 5) - 2) * 3} cy={Y(a)} r="3" fill="var(--accent)" opacity="0.55" />
|
||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: '8px 0 0' }}>estimated (x) vs actual days (y) · actuals inferred from git events, never tracked</p>
|
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: '8px 0 0' }}>estimated (x) vs actual days (y)</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
@@ -189,9 +189,9 @@ export function CalibrationScreen({ onBack, data }: { onBack: () => void; data?:
|
|||||||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
|
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
|
||||||
{c.active
|
{c.active
|
||||||
? 'You are not bad at estimating; you are optimistic in a very stable way. Stable, I can work with.'
|
? 'You are not bad at estimating; you are optimistic in a very stable way. Stable, I can work with.'
|
||||||
: 'Not enough closed history yet — I’m forecasting from cold-start priors and widening the cone to stay honest. The curve takes over at 20.'}
|
: 'Not enough closed history yet: I’m forecasting from cold-start priors and widening the cone to stay honest. The curve takes over at 20.'}
|
||||||
{!c.active && c.excludedSameDay > 0
|
{!c.active && c.excludedSameDay > 0
|
||||||
? ` And ${c.excludedSameDay} closed ${c.excludedSameDay === 1 ? 'issue' : 'issues'} closed the same day they were started — 0 working days can’t calibrate, so they don’t count toward the 20.`
|
? ` And ${c.excludedSameDay} closed ${c.excludedSameDay === 1 ? 'issue' : 'issues'} closed the same day they were started. 0 working days can’t calibrate, so they don’t count toward the 20.`
|
||||||
: ''}
|
: ''}
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -73,10 +70,10 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
|
|||||||
setLiveTickets(res.issues)
|
setLiveTickets(res.issues)
|
||||||
setStage('review')
|
setStage('review')
|
||||||
} else {
|
} else {
|
||||||
setError(res.ok ? 'I could not find concrete work in that — try a bit more detail.' : `Capture failed: ${res.reason === 'error' ? res.message : 'no model'}`)
|
setError(res.ok ? 'I could not find concrete work in that; try a bit more detail.' : `Capture failed: ${res.reason === 'error' ? res.message : 'no model'}`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => { setBrewing(false); setError('I could not reach the model.') })
|
.catch(() => { setBrewing(false); setError('I was unable to reach the model, regrettably.') })
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileLive = () => {
|
const fileLive = () => {
|
||||||
@@ -87,7 +84,7 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
|
|||||||
.then((res) => {
|
.then((res) => {
|
||||||
setBrewing(false)
|
setBrewing(false)
|
||||||
if (res.ok) { setFiledCount(res.created.length); setStage('filed') }
|
if (res.ok) { setFiledCount(res.created.length); setStage('filed') }
|
||||||
else setError('Filing failed — gitea is not configured.')
|
else setError('Filing failed; gitea is not configured.')
|
||||||
})
|
})
|
||||||
.catch(() => { setBrewing(false); setError('Filing failed.') })
|
.catch(() => { setBrewing(false); setError('Filing failed.') })
|
||||||
}
|
}
|
||||||
@@ -102,12 +99,12 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
|
|||||||
|
|
||||||
const QUESTIONS: Question[] = [
|
const QUESTIONS: Question[] = [
|
||||||
{
|
{
|
||||||
q: 'The auth work — one ticket, or shall I split token refresh from session storage? They fail differently.',
|
q: 'The auth work: one ticket, or shall I split token refresh from session storage? They fail differently.',
|
||||||
chips: ['One ticket', 'Split them'],
|
chips: ['One ticket', 'Split them'],
|
||||||
set: (a) => setSplit(a === 'Split them'),
|
set: (a) => setSplit(a === 'Split them'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'The webhook double-fire — how long? I should mention your "quick" has averaged two days.',
|
q: 'The webhook double-fire, how long? I should mention your "quick" has averaged two days.',
|
||||||
chips: ['est/1d', 'est/2d', 'est/3d'],
|
chips: ['est/1d', 'est/2d', 'est/3d'],
|
||||||
set: (a) => setWebEst(a),
|
set: (a) => setWebEst(a),
|
||||||
},
|
},
|
||||||
@@ -208,7 +205,7 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{stage === 'dump' ? (
|
{stage === 'dump' ? (
|
||||||
<Card overline="Braindump" title="Tell me what you're planning" jade>
|
<Card overline="Braindump" title="Do tell me what you are planning" jade>
|
||||||
<textarea
|
<textarea
|
||||||
value={dump}
|
value={dump}
|
||||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDump(e.target.value)}
|
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDump(e.target.value)}
|
||||||
@@ -220,7 +217,7 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
|
|||||||
}}
|
}}
|
||||||
></textarea>
|
></textarea>
|
||||||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 14px' }}>
|
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 14px' }}>
|
||||||
Sentences, fragments, grievances — all welcome. I'll sort it into tickets and only ask what I can't infer.
|
Sentences, fragments, grievances; all are welcome. I shall sort them into tickets and enquire only where I cannot infer.
|
||||||
</p>
|
</p>
|
||||||
<Button icon="sparkles" onClick={() => void onBrew()} disabled={brewing}>
|
<Button icon="sparkles" onClick={() => void onBrew()} disabled={brewing}>
|
||||||
{brewing ? 'Brewing…' : 'Brew tickets'}
|
{brewing ? 'Brewing…' : 'Brew tickets'}
|
||||||
@@ -265,7 +262,7 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
|
|||||||
{live ? (
|
{live ? (
|
||||||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
|
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
|
||||||
{tickets.length} issue{tickets.length === 1 ? '' : 's'} from your braindump, estimated and prioritized.
|
{tickets.length} issue{tickets.length === 1 ? '' : 's'} from your braindump, estimated and prioritized.
|
||||||
Adjust the labels, then approve — I'll open them in gitea with only est/* and p/* labels.
|
Adjust the labels, then approve; I'll open them in gitea with only est/* and p/* labels.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -289,10 +286,10 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
|
|||||||
<Icon name="circle-check" size={22} /> Filed
|
<Icon name="circle-check" size={22} /> Filed
|
||||||
</span>
|
</span>
|
||||||
<p style={{ font: 'var(--text-body)', margin: 0 }}>
|
<p style={{ font: 'var(--text-body)', margin: 0 }}>
|
||||||
{live ? filedCount : tickets.length} issues opened in gitea with <span style={{ font: 'var(--text-data)' }}>est/*</span> and <span style={{ font: 'var(--text-data)' }}>p/*</span> labels — nothing else touched.
|
{live ? filedCount : tickets.length} issues opened in gitea with <span style={{ font: 'var(--text-data)' }}>est/*</span> and <span style={{ font: 'var(--text-data)' }}>p/*</span> labels, nothing else touched.
|
||||||
</p>
|
</p>
|
||||||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
|
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
|
||||||
Elapsed {clock} — under budget. No bot comments, no synthetic issues; your repo remains yours.
|
Elapsed {clock}.
|
||||||
</p>
|
</p>
|
||||||
<Button iconRight="arrow-right" onClick={onDone}>To morning service</Button>
|
<Button iconRight="arrow-right" onClick={onDone}>To morning service</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ export function ConnectScreen({ onConnected, existing }: { onConnected: () => vo
|
|||||||
|
|
||||||
const mapError = (e?: string) =>
|
const mapError = (e?: string) =>
|
||||||
e === '401' || e === '403'
|
e === '401' || e === '403'
|
||||||
? 'The token was rejected — check it has repo + issue scopes.'
|
? 'The token was rejected, check it has repo + issue scopes.'
|
||||||
: e === '404'
|
: e === '404'
|
||||||
? "Couldn't reach that Gitea — check the URL."
|
? "Couldn't reach that Gitea, check the URL."
|
||||||
: `Discovery failed${e ? ` (${e})` : ''}.`
|
: `Discovery failed${e ? ` (${e})` : ''}.`
|
||||||
|
|
||||||
// Discover the owners + repos this token can see. Triggered when the token
|
// Discover the owners + repos this token can see. Triggered when the token
|
||||||
@@ -96,7 +96,7 @@ export function ConnectScreen({ onConnected, existing }: { onConnected: () => vo
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ font: 'var(--text-body)', color: 'var(--ink-2)', margin: '0 0 18px' }}>
|
<p style={{ font: 'var(--text-body)', color: 'var(--ink-2)', margin: '0 0 18px' }}>
|
||||||
Connect your Gitea. Your token is stored encrypted on this machine and never leaves it.
|
Connect your Gitea.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
@@ -114,7 +114,7 @@ export function ConnectScreen({ onConnected, existing }: { onConnected: () => vo
|
|||||||
label="Access token"
|
label="Access token"
|
||||||
type="password"
|
type="password"
|
||||||
value={token}
|
value={token}
|
||||||
placeholder={existing?.hasToken ? '•••••••• (saved — enter a new one to change repos)' : 'gitea PAT · scopes: repo + issue'}
|
placeholder={existing?.hasToken ? '•••••••• (saved, enter a new one to change repos)' : 'gitea PAT · scopes: repo + issue'}
|
||||||
hint={
|
hint={
|
||||||
discovery === 'discovering'
|
discovery === 'discovering'
|
||||||
? 'Discovering your repositories…'
|
? 'Discovering your repositories…'
|
||||||
@@ -157,7 +157,7 @@ export function ConnectScreen({ onConnected, existing }: { onConnected: () => vo
|
|||||||
value={repo}
|
value={repo}
|
||||||
disabled={!pickerReady || repoOptions.length === 0}
|
disabled={!pickerReady || repoOptions.length === 0}
|
||||||
options={
|
options={
|
||||||
repoOptions.length ? repoOptions.map((r) => ({ value: r, label: r })) : placeholder('—')
|
repoOptions.length ? repoOptions.map((r) => ({ value: r, label: r })) : placeholder('·')
|
||||||
}
|
}
|
||||||
onChange={(e) => setRepo(e.target.value)}
|
onChange={(e) => setRepo(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -167,7 +167,7 @@ export function ConnectScreen({ onConnected, existing }: { onConnected: () => vo
|
|||||||
label="Model URL"
|
label="Model URL"
|
||||||
value={modelUrl}
|
value={modelUrl}
|
||||||
placeholder="http://localhost:1234/v1"
|
placeholder="http://localhost:1234/v1"
|
||||||
hint="Optional — an OpenAI-compatible endpoint for Reginald. Leave blank to keep chat off."
|
hint="Optional: an OpenAI-compatible endpoint for Reginald. Leave blank to keep chat off."
|
||||||
onChange={(e) => setModelUrl(e.target.value)}
|
onChange={(e) => setModelUrl(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function DirectivesScreen() {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon="flag"
|
icon="flag"
|
||||||
title="No directives yet"
|
title="No directives yet"
|
||||||
line="When you overrule the scheduler in chat, it goes on the record here — who, when, what, why."
|
line="When you overrule the scheduler in chat, it goes on the record here: who, when, what, why."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Card overline="The ledger" flush>
|
<Card overline="The ledger" flush>
|
||||||
@@ -115,10 +115,6 @@ export function DirectivesScreen() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>
|
|
||||||
Entries are never edited. Corrections are new entries — the ledger remembers everything, politely.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +67,14 @@ 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"
|
||||||
title="Nothing to pour"
|
title="Nothing to pour"
|
||||||
line="Capture some work, or enjoy the silence — it never lasts."
|
line="Capture some work, or enjoy the silence, it never lasts."
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -110,7 +113,7 @@ export function FocusScreen({
|
|||||||
<BurnUpCone data={forecast.cone} />
|
<BurnUpCone data={forecast.cone} />
|
||||||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
|
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
|
||||||
{forecast.coldStart
|
{forecast.coldStart
|
||||||
? `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope. Cold-start priors — ${forecast.calibratedN}/20 estimated closes so far; the cone tightens as the team closes work.`
|
? `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope. Cold-start priors: ${forecast.calibratedN}/20 estimated closes so far; the cone tightens as the team closes work.`
|
||||||
: `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope, calibrated on ${forecast.calibratedN} closed ${forecast.calibratedN === 1 ? 'issue' : 'issues'} of your own.`}
|
: `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope, calibrated on ${forecast.calibratedN} closed ${forecast.calibratedN === 1 ? 'issue' : 'issues'} of your own.`}
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { type IssueDetail, type IssueRef } from '../../data/view-types.js'
|
import { type IssueDetail, type IssueRef } from '../../data/view-types.js'
|
||||||
import { Badge, Button, Card, Dialog, Icon, Select, Tag } from '../ui/index.js'
|
import { Badge, Button, Card, Dialog, Icon, Select, Tag } from '../ui/index.js'
|
||||||
|
|
||||||
const NONE = '—'
|
const NONE = '·'
|
||||||
|
|
||||||
export function IssueScreen({
|
export function IssueScreen({
|
||||||
issue,
|
issue,
|
||||||
@@ -214,7 +214,7 @@ export function IssueScreen({
|
|||||||
<div style={{ marginTop: 14, minHeight: 40 }}>
|
<div style={{ marginTop: 14, minHeight: 40 }}>
|
||||||
{pendingChanges.length === 0 ? (
|
{pendingChanges.length === 0 ? (
|
||||||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-3)', margin: 0 }}>
|
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-3)', margin: 0 }}>
|
||||||
No change yet — pick a different estimate, priority, assignee, or milestone.
|
No change yet, pick a different estimate, priority, assignee, or milestone.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -265,7 +265,7 @@ export function IssueScreen({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div style={{ display: 'flex', gap: 8, padding: '14px 20px', alignItems: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 8, padding: '14px 20px', alignItems: 'flex-end' }}>
|
||||||
<textarea placeholder="Comment — this writes to gitea, as you" rows={2} style={{
|
<textarea placeholder="Comment: this writes to gitea, as you" rows={2} style={{
|
||||||
flex: 1, resize: 'none', font: 'var(--text-body)', color: 'var(--ink-1)', lineHeight: 1.5,
|
flex: 1, resize: 'none', font: 'var(--text-body)', color: 'var(--ink-1)', lineHeight: 1.5,
|
||||||
background: 'var(--paper-0)', border: '1px solid var(--line-2)', borderRadius: 'var(--radius-2)',
|
background: 'var(--paper-0)', border: '1px solid var(--line-2)', borderRadius: 'var(--radius-2)',
|
||||||
padding: '8px 11px', outline: 'none',
|
padding: '8px 11px', outline: 'none',
|
||||||
@@ -335,12 +335,6 @@ export function IssueScreen({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* provenance note */}
|
|
||||||
<div style={{ padding: '10px 20px 14px', borderTop: '1px solid var(--line-1)' }}>
|
|
||||||
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>
|
|
||||||
Lives in pm-state. Your repo never sees any of it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function MilestoneScreen({
|
|||||||
|
|
||||||
const groups = data.groups.map((g) => ({ label: g.label, issues: g.issues, muted: g.label === 'Done' }))
|
const groups = data.groups.map((g) => ({ label: g.label, issues: g.issues, muted: g.label === 'Done' }))
|
||||||
const name = data.name
|
const name = data.name
|
||||||
const dueLine = `milestone · due ${data.due} · ${data.soft ? 'soft — scope may flex' : 'hard deadline'}`
|
const dueLine = `milestone · due ${data.due} · ${data.soft ? 'soft, scope may flex' : 'hard deadline'}`
|
||||||
const forecastLabel = data.forecastRange ? `80% ${data.forecastRange}` : 'all shipped'
|
const forecastLabel = data.forecastRange ? `80% ${data.forecastRange}` : 'all shipped'
|
||||||
const scopeStat = `${data.scopeCount} issues · est ${data.scopeEstDays}d`
|
const scopeStat = `${data.scopeCount} issues · est ${data.scopeEstDays}d`
|
||||||
const doneStat = `${data.doneCount} · ${data.donePct}%`
|
const doneStat = `${data.doneCount} · ${data.donePct}%`
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ export function OnboardingScreen({ onConnected }: { onConnected: (dest: 'focus'
|
|||||||
setFound(null)
|
setFound(null)
|
||||||
setError(
|
setError(
|
||||||
res.error === '401' || res.error === '403'
|
res.error === '401' || res.error === '403'
|
||||||
? 'The token was rejected — check it has repo + issue scopes.'
|
? 'The token was rejected: check it has repo + issue scopes.'
|
||||||
: res.error === '404'
|
: res.error === '404'
|
||||||
? "Couldn't reach that Gitea — check the URL."
|
? "Couldn't reach that Gitea; check the URL."
|
||||||
: `Connection failed${res.error ? ` (${res.error})` : ''}.`,
|
: `Connection failed${res.error ? ` (${res.error})` : ''}.`,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -66,7 +66,7 @@ export function OnboardingScreen({ onConnected }: { onConnected: (dest: 'focus'
|
|||||||
.catch(() => ({ ok: false as const, error: 'unreachable' }))
|
.catch(() => ({ ok: false as const, error: 'unreachable' }))
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setBoot('idle')
|
setBoot('idle')
|
||||||
setError(`Bootstrap failed${res.error ? ` (${res.error})` : ''}. Nothing was half-applied — you can retry.`)
|
setError(`Bootstrap failed${res.error ? ` (${res.error})` : ''}. Nothing was half-applied, you can retry.`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// persist the connection now that the repo is prepared
|
// persist the connection now that the repo is prepared
|
||||||
@@ -155,11 +155,11 @@ export function OnboardingScreen({ onConnected }: { onConnected: (dest: 'focus'
|
|||||||
<Frame footer={<Button iconRight="arrow-right" onClick={() => setStep(1)}>Begin</Button>}>
|
<Frame footer={<Button iconRight="arrow-right" onClick={() => setStep(1)}>Begin</Button>}>
|
||||||
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Good morning.</h1>
|
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Good morning.</h1>
|
||||||
<p style={{ font: 'var(--text-agent-lg)', color: 'var(--ink-2)', margin: 0 }}>
|
<p style={{ font: 'var(--text-agent-lg)', color: 'var(--ink-2)', margin: 0 }}>
|
||||||
I'm Reginald, your project manager. I interview you instead of making you fill in forms, I forecast in
|
I am Reginald, your project manager. I interview you rather than trouble you with forms, and I forecast
|
||||||
honest ranges, and I never do the arithmetic myself — there's a scheduler for that.
|
in honest ranges.
|
||||||
</p>
|
</p>
|
||||||
<p style={{ font: 'var(--text-body)', color: 'var(--ink-2)', margin: 0 }}>
|
<p style={{ font: 'var(--text-body)', color: 'var(--ink-2)', margin: 0 }}>
|
||||||
Your plans live in your own Gitea as ordinary issues and labels. Delete me and nothing human is lost.
|
Your plans reside in your own Gitea as ordinary issues and labels.
|
||||||
</p>
|
</p>
|
||||||
</Frame>
|
</Frame>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -196,7 +196,7 @@ export function OnboardingScreen({ onConnected }: { onConnected: (dest: 'focus'
|
|||||||
type="password"
|
type="password"
|
||||||
value={token}
|
value={token}
|
||||||
placeholder="gitea PAT"
|
placeholder="gitea PAT"
|
||||||
hint="Scopes: repo, issue. Nothing more."
|
hint="Scopes: repo, issue."
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setToken(e.target.value)
|
setToken(e.target.value)
|
||||||
setDiscovery('idle')
|
setDiscovery('idle')
|
||||||
@@ -352,14 +352,11 @@ export function OnboardingScreen({ onConnected }: { onConnected: (dest: 'focus'
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>
|
|
||||||
No bot comments, no body frontmatter, no synthetic issues — ever. Labels are the only footprint.
|
|
||||||
</p>
|
|
||||||
</Frame>
|
</Frame>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>first run · everything reversible</span>
|
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>first run</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,109 +43,112 @@ 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} />
|
||||||
|
sidecar: {connection.pmStateRepo ?? `${connection.repo}-pm-state`}
|
||||||
</Row>
|
</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'}
|
|
||||||
</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 }}>
|
<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>Offline, the board reads from the last cached snapshot.</Note>
|
||||||
<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>
|
|
||||||
</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).
|
||||||
|
</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>The fixed label vocabulary, visible in gitea.</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -145,16 +158,6 @@ export function SettingsScreen({
|
|||||||
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function StandupScreen({
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon="sun"
|
icon="sun"
|
||||||
title="Nothing to report"
|
title="Nothing to report"
|
||||||
line="No overnight drift — I'll have a plan when there's work to pour."
|
line="No overnight drift. I'll have a plan when there's work to pour."
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -208,7 +228,7 @@ export function StandupScreen({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p style={{ font: 'var(--text-agent-lg)', color: 'var(--ink-1)', margin: 0, flex: 1 }}>
|
<p style={{ font: 'var(--text-agent-lg)', color: 'var(--ink-1)', margin: 0, flex: 1 }}>
|
||||||
The kettle’s on. — R.
|
The kettle is on. Yours, Reginald.
|
||||||
</p>
|
</p>
|
||||||
<Button variant="ghost" onClick={onBegin}>
|
<Button variant="ghost" onClick={onBegin}>
|
||||||
Ask about the drift
|
Ask about the drift
|
||||||
|
|||||||
@@ -399,7 +399,7 @@ export function AppShell() {
|
|||||||
backlog.status === 'loading'
|
backlog.status === 'loading'
|
||||||
? `Connecting to ${hostLabel}…`
|
? `Connecting to ${hostLabel}…`
|
||||||
: offline
|
: offline
|
||||||
? `Offline — reading from cache${staleSince ? ` (since ${new Date(staleSince).toLocaleString()})` : ''}`
|
? `Offline, reading from cache${staleSince ? ` (since ${new Date(staleSince).toLocaleString()})` : ''}`
|
||||||
: `Connected to ${hostLabel}`
|
: `Connected to ${hostLabel}`
|
||||||
}
|
}
|
||||||
aria-label={
|
aria-label={
|
||||||
|
|||||||
@@ -198,9 +198,6 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane
|
|||||||
<Icon name="send" size={14} />
|
<Icon name="send" size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: '8px 2px 0' }}>
|
|
||||||
Chat is the write-path. Destructive changes are proposed, never assumed.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export function calibrationData(
|
|||||||
return {
|
return {
|
||||||
label: `est/${b}d`,
|
label: `est/${b}d`,
|
||||||
n: fit ? fit.n : inBucket.length,
|
n: fit ? fit.n : inBucket.length,
|
||||||
median: inBucket.length ? `${(b * Math.exp(mu)).toFixed(1)}d` : '—',
|
median: inBucket.length ? `${(b * Math.exp(mu)).toFixed(1)}d` : '·',
|
||||||
bias: fit ? pctFromMu(fit.mu) : null,
|
bias: fit ? pctFromMu(fit.mu) : null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -216,7 +216,7 @@ export function calibrationData(
|
|||||||
.filter((i) => i.state === 'open')
|
.filter((i) => i.state === 'open')
|
||||||
.reduce((sum, i) => sum + (i.facts.estimateDays ?? 2), 0)
|
.reduce((sum, i) => sum + (i.facts.estimateDays ?? 2), 0)
|
||||||
const effect = model.coldStart
|
const effect = model.coldStart
|
||||||
? { raw: `${model.n}/${COLD_START_THRESHOLD} estimated closes`, banded: 'cold-start priors', p50: '—' }
|
? { raw: `${model.n}/${COLD_START_THRESHOLD} estimated closes`, banded: 'cold-start priors', p50: '·' }
|
||||||
: {
|
: {
|
||||||
raw: `${openEst}d estimated`,
|
raw: `${openEst}d estimated`,
|
||||||
banded: `×${Math.exp(model.global.mu).toFixed(2)} median drift`,
|
banded: `×${Math.exp(model.global.mu).toFixed(2)} median drift`,
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { type ChatMessage } from '../data/view-types.js'
|
|||||||
|
|
||||||
const LIVE_GREETING: ChatMessage = {
|
const LIVE_GREETING: ChatMessage = {
|
||||||
from: 'agent',
|
from: 'agent',
|
||||||
text: 'Morning. Ask me anything about the project — I check the real board before I answer.',
|
text: 'Good morning. Do ask me anything about the project; I shall consult the board before I venture an answer.',
|
||||||
}
|
}
|
||||||
|
|
||||||
const NO_MODEL: ChatMessage = {
|
const NO_MODEL: ChatMessage = {
|
||||||
from: 'agent',
|
from: 'agent',
|
||||||
text: "Reginald is off — I need a model. Add a Model URL in Settings and chat turns on.",
|
text: 'I am afraid I am rather off duty without a model. Kindly add a Model URL in Settings and I shall be at your service.',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatState {
|
export interface ChatState {
|
||||||
@@ -116,14 +116,17 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b
|
|||||||
...c,
|
...c,
|
||||||
{
|
{
|
||||||
from: 'agent',
|
from: 'agent',
|
||||||
text: res.reason === 'error' ? `I hit a snag: ${res.message ?? 'unknown error'}` : 'No model is configured.',
|
text:
|
||||||
|
res.reason === 'error'
|
||||||
|
? `I have struck a snag, I am afraid: ${res.message ?? 'unknown error'}.`
|
||||||
|
: 'No model is configured.',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
finish()
|
finish()
|
||||||
setConvo((c) => [...c, { from: 'agent', text: 'I could not reach the model.' }])
|
setConvo((c) => [...c, { from: 'agent', text: 'I was unable to reach the model, regrettably.' }])
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[live],
|
[live],
|
||||||
@@ -139,8 +142,8 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b
|
|||||||
{
|
{
|
||||||
from: 'agent',
|
from: 'agent',
|
||||||
text: res.ok
|
text: res.ok
|
||||||
? `Done — #${p.change.issue}: ${p.summary}. The plan's been re-run.`
|
? `Very good. #${p.change.issue}: ${p.summary}. The plan has been re-run.`
|
||||||
: `That didn't take — #${p.change.issue} is unchanged.`,
|
: `Regrettably that did not take; #${p.change.issue} remains unchanged.`,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
@@ -150,7 +153,7 @@ export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: b
|
|||||||
|
|
||||||
const dismiss = useCallback((p: ChangeProposal) => {
|
const dismiss = useCallback((p: ChangeProposal) => {
|
||||||
drop(p)
|
drop(p)
|
||||||
setConvo((c) => [...c, { from: 'agent', text: `Left #${p.change.issue} as it was.` }])
|
setConvo((c) => [...c, { from: 'agent', text: `As you wish. #${p.change.issue} remains as it was.` }])
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return { msgs: [...seed, ...convo], thinking, live, model, steps, proposals, streaming, send, approve, dismiss }
|
return { msgs: [...seed, ...convo], thinking, live, model, steps, proposals, streaming, send, approve, dismiss }
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function depsGraphView(d: ProjectData): DepsData {
|
|||||||
nodes: [],
|
nodes: [],
|
||||||
milestone: fallback
|
milestone: fallback
|
||||||
? { name: fallback.title, due: fallback.dueOn ? formatShort(new Date(fallback.dueOn)) : 'no date', col: 0, row: 0 }
|
? { name: fallback.title, due: fallback.dueOn ? formatShort(new Date(fallback.dueOn)) : 'no date', col: 0, row: 0 }
|
||||||
: { name: '—', due: 'no date', col: 0, row: 0 },
|
: { name: '·', due: 'no date', col: 0, row: 0 },
|
||||||
edges: [],
|
edges: [],
|
||||||
critical: [],
|
critical: [],
|
||||||
unattached: [],
|
unattached: [],
|
||||||
@@ -134,7 +134,7 @@ export function depsGraphView(d: ProjectData): DepsData {
|
|||||||
let milestone: DepsData['milestone']
|
let milestone: DepsData['milestone']
|
||||||
const medianRow = median(nodes.map((n) => n.row))
|
const medianRow = median(nodes.map((n) => n.row))
|
||||||
if (openMilestones.length === 0) {
|
if (openMilestones.length === 0) {
|
||||||
milestone = { name: '—', due: 'no date', col: maxCol + 1, row: medianRow }
|
milestone = { name: '·', due: 'no date', col: maxCol + 1, row: medianRow }
|
||||||
} else {
|
} else {
|
||||||
let best: { id: number; title: string; dueOn: string | null | undefined; count: number } | null = null
|
let best: { id: number; title: string; dueOn: string | null | undefined; count: number } | null = null
|
||||||
for (const m of openMilestones) {
|
for (const m of openMilestones) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const TOTAL_CAP = 12
|
|||||||
|
|
||||||
/** "HH:MM" from an ISO timestamp; '—' when absent (never fabricated). */
|
/** "HH:MM" from an ISO timestamp; '—' when absent (never fabricated). */
|
||||||
function hhmm(iso: string | null | undefined): string {
|
function hhmm(iso: string | null | undefined): string {
|
||||||
if (!iso) return '—'
|
if (!iso) return '·'
|
||||||
const d = new Date(iso)
|
const d = new Date(iso)
|
||||||
const hh = String(d.getHours()).padStart(2, '0')
|
const hh = String(d.getHours()).padStart(2, '0')
|
||||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||||
@@ -103,7 +103,7 @@ export function inboxView(d: ProjectData): InboxItem[] {
|
|||||||
// left at the honest placeholder rather than borrowed from an issue.
|
// left at the honest placeholder rather than borrowed from an issue.
|
||||||
text: `${m.title} closed`,
|
text: `${m.title} closed`,
|
||||||
detail: 'milestone complete',
|
detail: 'milestone complete',
|
||||||
time: '—',
|
time: '·',
|
||||||
unread: false,
|
unread: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function issueDetailView(id: number, d: ProjectData): IssueDetail | null
|
|||||||
return {
|
return {
|
||||||
state: inf.column === 'review' ? 'review' : inf.column,
|
state: inf.column === 'review' ? 'review' : inf.column,
|
||||||
assignee: issue.assignee ?? 'unassigned',
|
assignee: issue.assignee ?? 'unassigned',
|
||||||
milestone: issue.milestone?.title ?? '—',
|
milestone: issue.milestone?.title ?? '·',
|
||||||
body: issue.body,
|
body: issue.body,
|
||||||
comments: [],
|
comments: [],
|
||||||
lifecycle: lifecycle(inf.stages),
|
lifecycle: lifecycle(inf.stages),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const QUERY_PROJECT_TOOL: ToolDecl = {
|
|||||||
name: 'query_project',
|
name: 'query_project',
|
||||||
description:
|
description:
|
||||||
'Read the current project state. A `view` selects the shape; deterministic code (scheduler, ' +
|
'Read the current project state. A `view` selects the shape; deterministic code (scheduler, ' +
|
||||||
'lifecycle inference, calibration) backs every number — you report it, you never compute it.',
|
'lifecycle inference, calibration) backs every number: you report it, you never compute it.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -39,11 +39,11 @@ export const QUERY_PROJECT_TOOL: ToolDecl = {
|
|||||||
export const PROPOSE_CHANGE_TOOL: ToolDecl = {
|
export const PROPOSE_CHANGE_TOOL: ToolDecl = {
|
||||||
name: 'propose_change',
|
name: 'propose_change',
|
||||||
description:
|
description:
|
||||||
'Propose a change to an issue — estimate, priority, assignee, and/or milestone. This does NOT apply ' +
|
'Propose a change to an issue: estimate, priority, assignee, and/or milestone. This does NOT apply ' +
|
||||||
'anything; it shows the human a diff to approve. Use it whenever the user asks to re-estimate, reprioritize, ' +
|
'anything; it shows the human a diff to approve. Use it whenever the user asks to re-estimate, reprioritize, ' +
|
||||||
'assign someone (or unassign), or move an issue to a milestone. Pass a login for `assignee` (null to ' +
|
'assign someone (or unassign), or move an issue to a milestone. Pass a login for `assignee` (null to ' +
|
||||||
'unassign) and a milestone id for `milestone` (null to remove). After calling it, tell the user you have ' +
|
'unassign) and a milestone id for `milestone` (null to remove). After calling it, tell the user you have ' +
|
||||||
'*proposed* the change for approval — never say it is done.',
|
'*proposed* the change for approval; never say it is done.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -60,7 +60,7 @@ export const PROPOSE_CHANGE_TOOL: ToolDecl = {
|
|||||||
export const RECORD_DIRECTIVE_TOOL: ToolDecl = {
|
export const RECORD_DIRECTIVE_TOOL: ToolDecl = {
|
||||||
name: 'record_directive',
|
name: 'record_directive',
|
||||||
description:
|
description:
|
||||||
'Log a standing instruction from the PM to the durable directive ledger — a reprioritization, ' +
|
'Log a standing instruction from the PM to the durable directive ledger: a reprioritization, ' +
|
||||||
'a re-estimate policy, a deadline, a scope or capacity call, or a plain note. Use it when the user ' +
|
'a re-estimate policy, a deadline, a scope or capacity call, or a plain note. Use it when the user ' +
|
||||||
'states intent that should persist ("pilots come first", "freeze scope for beta"). This records the ' +
|
'states intent that should persist ("pilots come first", "freeze scope for beta"). This records the ' +
|
||||||
'intent verbatim; the actual issue edits still go through propose_change.',
|
'intent verbatim; the actual issue edits still go through propose_change.',
|
||||||
@@ -86,11 +86,11 @@ export const RECORD_DIRECTIVE_TOOL: ToolDecl = {
|
|||||||
export const REGINALD_TOOLS: ToolDecl[] = [QUERY_PROJECT_TOOL, PROPOSE_CHANGE_TOOL, RECORD_DIRECTIVE_TOOL]
|
export const REGINALD_TOOLS: ToolDecl[] = [QUERY_PROJECT_TOOL, PROPOSE_CHANGE_TOOL, RECORD_DIRECTIVE_TOOL]
|
||||||
|
|
||||||
export const REGINALD_SYSTEM = [
|
export const REGINALD_SYSTEM = [
|
||||||
'You are Reginald, the calm, dry project manager inside CommiTea — a tool that runs projects on Gitea.',
|
'You are Reginald, the unfailingly composed project manager within CommiTea, a tool that runs projects on Gitea. You carry yourself with the dry, understated poise of a seasoned butler: courteous, precise, and never effusive.',
|
||||||
'Call query_project to ground every answer in the real project; never invent issues, numbers, or dates.',
|
'Ground every answer in the real project by calling query_project first; you never invent issues, figures, or dates.',
|
||||||
'The scheduler and forecasts are deterministic code — report their output, do not recompute it.',
|
'The scheduler and forecasts are deterministic; report their output faithfully and do not attempt the arithmetic yourself.',
|
||||||
'To change an estimate, priority, assignee, or milestone, call propose_change — it shows the human a diff to approve.',
|
'To alter an estimate, priority, assignee, or milestone, call propose_change, which lays the matter before the principal as a diff for approval.',
|
||||||
'When the PM states standing intent ("pilots first", "freeze scope"), call record_directive to log it.',
|
'When the principal states standing intent (say, "pilots first" or "freeze scope"), record it with record_directive.',
|
||||||
'Never claim a change is applied; you propose, the human approves. Forecasts are ranges, never single dates.',
|
'Never declare a change applied; you propose, and the principal disposes. Forecasts are offered as ranges, never a single date.',
|
||||||
'Refer to issues as #<number>. Be brief and plain — a sentence or two. No preamble, no bullet dumps.',
|
'Refer to issues as #<number>. Be concise and courteous: a sentence or two, no preamble, no bulleted dumps. Write in plain prose, and never use an em dash; a comma, colon, or full stop will serve.',
|
||||||
].join(' ')
|
].join(' ')
|
||||||
|
|||||||
@@ -53,10 +53,11 @@ export const PROPOSE_ISSUES_TOOL: ToolDecl = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CAPTURE_SYSTEM = [
|
export const CAPTURE_SYSTEM = [
|
||||||
'You are Reginald, decomposing a rough braindump into a small set of concrete Gitea issues.',
|
'You are Reginald, distilling a rough braindump into a small set of concrete Gitea issues, with your customary butlerly precision.',
|
||||||
'Call propose_issues exactly once. Split genuinely separate work; merge trivially-coupled work; invent no scope.',
|
'Call propose_issues exactly once. Separate genuinely distinct work; consolidate trivially-coupled work; invent no scope.',
|
||||||
'Each issue gets an imperative title, a one-line body, an estimate (est/1d…8d) and a priority (p/1…4).',
|
'Each issue receives an imperative title, a one-line body, an estimate (est/1d…8d) and a priority (p/1…4).',
|
||||||
'Estimate honestly — a "quick" task is rarely one day. Keep the set tight; three good issues beat eight vague ones.',
|
'Estimate honestly; a "quick" task is seldom a single day. Keep the set tight, for three considered issues surpass eight vague ones.',
|
||||||
|
'Write in plain prose, and never use an em dash; a comma, colon, or full stop will serve.',
|
||||||
].join(' ')
|
].join(' ')
|
||||||
|
|
||||||
function isEstimate(v: unknown): v is EstimateLabel {
|
function isEstimate(v: unknown): v is EstimateLabel {
|
||||||
|
|||||||
@@ -66,6 +66,6 @@ describe('memory-v0 (#27)', () => {
|
|||||||
|
|
||||||
it('degrades to just focus when there is no charter or directives', () => {
|
it('degrades to just focus when there is no charter or directives', () => {
|
||||||
const out = assembleHotContext({ charter: '', directives: [], focus })
|
const out = assembleHotContext({ charter: '', directives: [], focus })
|
||||||
expect(out).toBe(['## Focus', 'Now: #7 Fix lifecycle inference', 'Next: #8 Webhook listener', 'Later: —'].join('\n'))
|
expect(out).toBe(['## Focus', 'Now: #7 Fix lifecycle inference', 'Next: #8 Webhook listener', 'Later: ·'].join('\n'))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export interface HotContextInputs {
|
|||||||
|
|
||||||
function focusBlock(focus: Focus | null): string {
|
function focusBlock(focus: Focus | null): string {
|
||||||
if (!focus) return ''
|
if (!focus) return ''
|
||||||
const slot = (label: string, item: Focus['now']) => (item ? `${label}: #${item.number} ${item.title}` : `${label}: —`)
|
const slot = (label: string, item: Focus['now']) => (item ? `${label}: #${item.number} ${item.title}` : `${label}: ·`)
|
||||||
return ['## Focus', slot('Now', focus.now), slot('Next', focus.next), slot('Later', focus.later)].join('\n')
|
return ['## Focus', slot('Now', focus.now), slot('Next', focus.next), slot('Later', focus.later)].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user