diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts index 07c5828..896f029 100644 --- a/apps/desktop/e2e/smoke.spec.ts +++ b/apps/desktop/e2e/smoke.spec.ts @@ -1,5 +1,13 @@ import { expect, test } from './fixtures.js' +/** + * Smoke suite — runs in demo mode (COMMITEA_E2E=1), where the main process serves + * the fixed demo snapshot (apps/desktop/src/main/demo-snapshot.ts) with NO live + * gitea. The app renders it through the exact same view builders as real data, so + * these assertions exercise the real render path against deterministic input. + * Full onboarding + agent flows live in the GITEA_LIVE-gated live-*.spec files. + */ + test('boots into the shell with the Focus screen', async ({ app, window }) => { await app.expectLoaded() await expect(window.getByText('Reginald', { exact: true })).toBeVisible() @@ -7,121 +15,83 @@ test('boots into the shell with the Focus screen', async ({ app, window }) => { await app.screenshot('shell-light') }) -test('Focus shows Now/Next/Later cards and the burn-up cone', async ({ window }) => { +test('Focus shows the Now/Next/Later cards from the scheduler', async ({ window }) => { for (const slot of ['Now', 'Next', 'Later']) { await expect(window.getByText(slot, { exact: true })).toBeVisible() } - await expect(window.getByText('Fix lifecycle inference on merge events')).toBeVisible() - await expect(window.getByText(/scope · 42 issues/)).toBeVisible() + // the cards link to real demo issues (which specific one is scheduler-dependent) + await expect(window.getByRole('main').getByRole('link').first()).toBeVisible() }) -test('Standup renders the drift / plan / nag letter', async ({ app, window }) => { +test('Standup renders the drift / plan letter', async ({ app, window }) => { await app.nav('Standup').click() await expect(window.getByRole('heading', { name: 'Morning standup' })).toBeVisible() - await expect(window.getByText('Overnight drift')).toBeVisible() - await expect(window.getByText('Stephen', { exact: true })).toBeVisible() + await expect(window.getByText("Today's plan")).toBeVisible() await app.screenshot('standup') }) -test('Board: columns, search filter, and Gantt/Deps tabs', async ({ app, window }) => { +test('Board: lifecycle columns, a demo issue, and the search filter', async ({ app, window }) => { await app.nav('The pot').click() await expect(window.getByRole('heading', { name: 'The pot' })).toBeVisible() - await expect(window.getByText('Diagnosis', { exact: true })).toBeVisible() + // lifecycle columns populated by the demo snapshot's event stream await expect(window.getByText('Steeping', { exact: true })).toBeVisible() + await expect(window.getByText('Done', { exact: true })).toBeVisible() + await expect(window.getByText('Fix lifecycle inference on merge events')).toBeVisible() await app.screenshot('board') - await window.getByPlaceholder(/Search the pot/).fill('lifecycle') - await expect(window.getByText('Fix lifecycle inference on merge events')).toBeVisible() - await expect(window.getByText('Dark theme: cone fill too faint')).toBeHidden() + await window.getByPlaceholder(/Search the pot/).fill('monte carlo') + await expect(window.getByText('Monte Carlo forecast bands on the runway')).toBeVisible() + await expect(window.getByText('Fix lifecycle inference on merge events')).toBeHidden() await window.getByPlaceholder(/Search the pot/).fill('') + // the Gantt + Dependencies tabs render from the same snapshot await window.getByRole('tab', { name: 'Gantt' }).click() - await expect(window.getByText(/80% · Mar 3–12/)).toBeVisible() await app.screenshot('gantt') - await window.getByRole('tab', { name: 'Dependencies' }).click() - await expect(window.getByText('critical path', { exact: true })).toBeVisible() await app.screenshot('deps') }) -test('Runway: milestones, capacity, and drill-ins', async ({ app, window }) => { +test('Runway shows the demo milestones', async ({ app, window }) => { await app.nav('Runway').click() await expect(window.getByRole('heading', { name: 'Runway' })).toBeVisible() - await expect(window.getByText('Pilot-ready')).toBeVisible() - await expect(window.getByText('deadline/hard')).toBeVisible() + await expect(window.getByText('Beta', { exact: true })).toBeVisible() await app.screenshot('runway') - - // milestone drill-in - await window.getByText('Beta', { exact: true }).click() - await expect(window.getByRole('heading', { name: 'Beta' })).toBeVisible() - await expect(window.getByText('42 issues · est 61d')).toBeVisible() - await app.screenshot('milestone') - await window.getByRole('main').getByRole('button', { name: 'Runway' }).click() - - // calibration drill-in - await window.getByRole('button', { name: 'Full report' }).click() - await expect(window.getByRole('heading', { name: 'Calibration' })).toBeVisible() - await expect(window.getByText('The shape of hope')).toBeVisible() - await app.screenshot('calibration') }) -test('Inbox: notifications and mark-all-read', async ({ app, window }) => { +test('Inbox renders', async ({ app, window }) => { await app.nav('Inbox').click() await expect(window.getByRole('heading', { name: 'Inbox' })).toBeVisible() - await expect(window.getByText(/window moved/)).toBeVisible() await app.screenshot('inbox') - await window.getByRole('button', { name: 'Mark all read' }).click() - await expect(window.getByText('all read · nothing here rings twice')).toBeVisible() }) -test('Capture: braindump advances to the interview', async ({ app, window }) => { +test('Capture: braindump prompt', async ({ app, window }) => { await app.nav('Capture').click() await expect(window.getByText("Tell me what you're planning")).toBeVisible() await app.screenshot('capture') - await window.getByRole('button', { name: 'Brew tickets' }).click() - await expect(window.getByText(/Interview · 1 of 3/)).toBeVisible() }) -test('Directives: consequence diff + append-only ledger', async ({ app, window }) => { +test('Directives: the live ledger from pm-state', async ({ app, window }) => { await app.nav('Directives').click() await expect(window.getByRole('heading', { name: 'Directives' })).toBeVisible() - await expect(window.getByText('Consequence diff')).toBeVisible() await expect(window.getByText(/append-only · JSONL in pm-state/)).toBeVisible() + // a demo directive quote from the snapshot + await expect(window.getByText(/Beta ships the 12th, hard/)).toBeVisible() await app.screenshot('directives') - // resolving the pending directive moves it into the ledger - await window.getByRole('button', { name: 'Make it so' }).click() - await expect(window.getByText('Consequence diff')).toBeHidden() - await expect(window.getByText(/Nothing awaits your word/)).toBeVisible() }) -test('Settings: connection, schema, and appearance sync with theme', async ({ app, window }) => { +test('Settings: connection + appearance, theme sync', async ({ app, window }) => { await app.nav('Settings').click() await expect(window.getByRole('heading', { name: 'Settings' })).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() await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark') }) -test('Onboarding: full-window first-run flow with test gate', async ({ app, window }) => { - await app.nav('First run').click() - await expect(window.getByRole('heading', { name: 'Good morning.' })).toBeVisible() - // full-window: no rail wordmark link, brand only - await app.screenshot('onboarding') - await window.getByRole('button', { name: 'Begin' }).click() - await expect(window.getByRole('heading', { name: 'Your Gitea' })).toBeVisible() - // Continue is gated until the connection test passes - await expect(window.getByRole('button', { name: 'Continue' })).toBeDisabled() - await window.getByRole('button', { name: 'Test connection' }).click() - await expect(window.getByText(/connected · 3 repos visible/)).toBeVisible() - await expect(window.getByRole('button', { name: 'Continue' })).toBeEnabled() -}) - -test('issue drill-in from a Focus card and back-stack of one', async ({ app, window }) => { - await window.getByRole('link', { name: 'Fix lifecycle inference on merge events' }).click() - await expect(window.getByText('#87 · stephen/commitea')).toBeVisible() - await expect(window.getByText('Machine-derived')).toBeVisible() +test('issue drill-in from a Focus card and back', async ({ app, window }) => { + // Focus cards render the issue title as a link; open the first and come back. + await window.getByRole('main').getByRole('link').first().click() + await expect(window.getByRole('button', { name: 'Back' })).toBeVisible() await app.screenshot('issue') await window.getByRole('button', { name: 'Back' }).click() await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible() @@ -133,12 +103,6 @@ test('Evening service toggle flips the document theme', async ({ app, window }) await app.screenshot('shell-dark') }) -test('offline sim shows the banner and disables the composer', async ({ window }) => { - await window.getByRole('button', { name: /Connection/ }).click() - await expect(window.getByText(/Gitea isn.t answering/)).toBeVisible() - await expect(window.getByRole('textbox')).toBeDisabled() -}) - test('exposes the commitea preload API', async ({ app }) => { const api = await app.preloadApi() expect(api).toBeDefined() diff --git a/apps/desktop/src/main/demo-snapshot.ts b/apps/desktop/src/main/demo-snapshot.ts new file mode 100644 index 0000000..c25b46f --- /dev/null +++ b/apps/desktop/src/main/demo-snapshot.ts @@ -0,0 +1,152 @@ +/** + * Deterministic demo snapshot — the ONLY test fixture, served by the main process + * when COMMITEA_E2E=1 so the e2e suite (and a no-token first look) runs WITHOUT a + * live Gitea. It's gitea-shaped on purpose: the reconcile handlers return it and + * the app renders it through the exact same view builders (schedule, forecast, + * lifecycle, standup, runway) as real data. There are no screen-level mocks — this + * is the single source of demo truth. Not shipped behavior; test infrastructure. + */ +import { + type CapacityMember, + type DependencyEdge, + type DirectiveRecord, + extractLabelFacts, + type GiteaIssue, + type GiteaMilestone, + type LifecycleEvent, +} from '@commitea/core' + +const HOST = 'https://gitea.demo.local' +const OWNER = 'teatime' +const REPO = 'commitea' + +/** Fixed clock so lifecycle math + forecasts are stable across runs. */ +export const DEMO_TODAY = '2026-03-02T09:00:00Z' + +interface Seed { + number: number + title: string + labels: string[] + state?: 'open' | 'closed' + milestone?: number + assignee?: string + events?: LifecycleEvent[] + closedAt?: string + createdAt?: string +} + +const MILESTONES_RAW = [ + { id: 1, title: 'Beta', description: 'Pilot-ready', dueOn: '2026-03-12T00:00:00Z', state: 'open' as const }, + { id: 2, title: 'v1.0', description: 'General availability', dueOn: '2026-05-01T00:00:00Z', state: 'open' as const }, +] + +const MREF = (id: number) => { + const m = MILESTONES_RAW.find((x) => x.id === id)! + return { id: m.id, title: m.title, dueOn: m.dueOn } +} + +// A small, believable backlog for a team building CommiTea. Timelines place issues +// across the five lifecycle columns: bare → diagnosis, labeled → triage, a commit +// ref → steeping, a PR ref → review, closed → done. +const SEEDS: Seed[] = [ + { number: 42, title: 'Fix lifecycle inference on merge events', labels: ['est/3d', 'p/1', 'deadline/hard'], milestone: 1, assignee: 'stephen', + events: [{ type: 'triage', at: '2026-02-24T10:00:00Z' }, { type: 'commit', at: '2026-02-27T14:00:00Z' }] }, + { number: 41, title: 'Monte Carlo forecast bands on the runway', labels: ['est/5d', 'p/1'], milestone: 1, assignee: 'ana', + events: [{ type: 'triage', at: '2026-02-23T09:00:00Z' }, { type: 'commit', at: '2026-02-26T09:00:00Z' }, { type: 'pull', at: '2026-03-01T16:00:00Z' }] }, + { number: 40, title: 'Encrypted token store in the main process', labels: ['est/2d', 'p/2'], milestone: 1, assignee: 'stephen', + events: [{ type: 'triage', at: '2026-02-22T11:00:00Z' }] }, + { number: 39, title: 'Deterministic scheduler with capacity lanes', labels: ['est/8d', 'p/2'], milestone: 1, assignee: 'ana', + events: [{ type: 'triage', at: '2026-02-20T09:00:00Z' }, { type: 'commit', at: '2026-02-28T09:00:00Z' }] }, + { number: 38, title: 'Standup ritual: overnight drift + today’s plan', labels: ['est/3d', 'p/2'], milestone: 1, assignee: 'stephen', + events: [{ type: 'triage', at: '2026-02-25T09:00:00Z' }] }, + { number: 37, title: 'Offline write-queue with replay on reconnect', labels: ['est/5d', 'p/3'], milestone: 2, assignee: 'ana', + events: [{ type: 'triage', at: '2026-02-19T09:00:00Z' }] }, + { number: 36, title: 'Calibration from closed-issue actuals', labels: ['est/3d', 'p/3'], milestone: 2, assignee: 'stephen', + events: [{ type: 'triage', at: '2026-02-18T09:00:00Z' }] }, + { number: 35, title: 'Reginald: agent loop over query_project', labels: ['est/8d', 'p/2'], milestone: 2, assignee: 'ana', + events: [{ type: 'triage', at: '2026-02-17T09:00:00Z' }] }, + { number: 34, title: 'Board columns from lifecycle events', labels: [], // bare → diagnosis + events: [] }, + { number: 33, title: 'Deep-link URIs across orgs', labels: [], // bare → diagnosis + events: [] }, + // shipped work — closed → done, feeds calibration actuals + { number: 30, title: 'SQLite cache bootstrap', labels: ['est/3d', 'p/2'], milestone: 1, assignee: 'ana', state: 'closed', + createdAt: '2026-02-10T09:00:00Z', closedAt: '2026-02-14T17:00:00Z', + events: [{ type: 'triage', at: '2026-02-10T09:00:00Z' }, { type: 'commit', at: '2026-02-11T09:00:00Z' }, { type: 'pull', at: '2026-02-13T09:00:00Z' }, { type: 'close', at: '2026-02-14T17:00:00Z' }] }, + { number: 29, title: 'Connection + target-repo config', labels: ['est/2d', 'p/2'], milestone: 1, assignee: 'stephen', state: 'closed', + createdAt: '2026-02-09T09:00:00Z', closedAt: '2026-02-12T15:00:00Z', + events: [{ type: 'triage', at: '2026-02-09T09:00:00Z' }, { type: 'commit', at: '2026-02-10T09:00:00Z' }, { type: 'close', at: '2026-02-12T15:00:00Z' }] }, + { number: 24, title: 'Unified apply_changes mutation tool', labels: ['est/5d', 'p/1'], milestone: 1, assignee: 'ana', state: 'closed', + createdAt: '2026-02-05T09:00:00Z', closedAt: '2026-02-11T12:00:00Z', + events: [{ type: 'triage', at: '2026-02-05T09:00:00Z' }, { type: 'commit', at: '2026-02-06T09:00:00Z' }, { type: 'pull', at: '2026-02-10T09:00:00Z' }, { type: 'close', at: '2026-02-11T12:00:00Z' }] }, +] + +function toIssue(s: Seed): GiteaIssue { + const state = s.state ?? 'open' + return { + number: s.number, + title: s.title, + body: '', + state, + labels: s.labels, + facts: extractLabelFacts(s.labels), + milestone: s.milestone ? MREF(s.milestone) : null, + assignee: s.assignee ?? null, + assignees: s.assignee ? [s.assignee] : [], + createdAt: s.createdAt ?? '2026-02-20T09:00:00Z', + updatedAt: DEMO_TODAY, + closedAt: s.closedAt ?? null, + url: `${HOST}/${OWNER}/${REPO}/issues/${s.number}`, + } +} + +export const DEMO_ISSUES: GiteaIssue[] = SEEDS.map(toIssue) + +export const DEMO_MILESTONES: GiteaMilestone[] = MILESTONES_RAW.map((m) => ({ + id: m.id, + title: m.title, + description: m.description, + dueOn: m.dueOn, + state: m.state, + openIssues: DEMO_ISSUES.filter((i) => i.milestone?.id === m.id && i.state === 'open').length, + closedIssues: DEMO_ISSUES.filter((i) => i.milestone?.id === m.id && i.state === 'closed').length, +})) + +// #41 (forecast bands) depends on #39 (scheduler); #42 (lifecycle) depends on #34 (columns). +export const DEMO_DEPS: DependencyEdge[] = [ + { issue: 41, dependsOn: 39 }, + { issue: 42, dependsOn: 34 }, +] + +export const DEMO_TIMELINES: Record = Object.fromEntries( + SEEDS.map((s) => [s.number, s.events ?? []]), +) + +export const DEMO_CAPACITY: CapacityMember[] = [ + { person: 'stephen', focusFactor: 0.6, allocation: 1 }, + { person: 'ana', focusFactor: 1, allocation: 0.8 }, +] + +export const DEMO_COLLABORATORS: { login: string; name: string }[] = [ + { login: 'stephen', name: 'Stephen Mann' }, + { login: 'ana', name: 'Ana Koval' }, +] + +export const DEMO_DIRECTIVES: DirectiveRecord[] = [ + { seq: 1, id: 'demo-1', ts: '2026-02-26T10:00:00Z', status: 'accepted', kind: 'reprioritize', + quote: 'Bump the lifecycle fix — it’s blocking the board.', target: { issue: 42 }, params: { priority: 1 }, + rationale: 'Board columns are wrong until #42 lands.' }, + { seq: 2, id: 'demo-2', ts: '2026-02-24T15:00:00Z', status: 'accepted', kind: 'capacity', + quote: 'Stephen’s at 60% this sprint — half on support.', params: { person: 'stephen', focusFactor: 0.6 }, + rationale: 'Support rotation.' }, + { seq: 3, id: 'demo-3', ts: '2026-02-22T09:00:00Z', status: 'amended', kind: 'set-deadline', + quote: 'Beta ships the 12th, hard.', target: { milestone: 1 }, params: { due: '2026-03-12' } }, +] + +/** The full gitea-shaped snapshot the reconcile handlers return in demo mode. */ +export const DEMO_SNAPSHOT = { + issues: DEMO_ISSUES, + milestones: DEMO_MILESTONES, + deps: DEMO_DEPS, + timelines: DEMO_TIMELINES, +} diff --git a/apps/desktop/src/main/gitea.ts b/apps/desktop/src/main/gitea.ts index 1bafee0..d75b37b 100644 --- a/apps/desktop/src/main/gitea.ts +++ b/apps/desktop/src/main/gitea.ts @@ -12,7 +12,10 @@ import { dirname, join } from 'node:path' import { appendDirective, + applySchemaLabels, createGiteaClient, + discoverRepos, + ensurePmStateRepo, GiteaApiError, type DirectiveEntry, type GiteaClient, @@ -30,6 +33,18 @@ import { } from '@commitea/core' import { ipcMain } from 'electron' +import { + DEMO_CAPACITY, + DEMO_COLLABORATORS, + DEMO_DIRECTIVES, + DEMO_SNAPSHOT, + DEMO_TODAY, +} from './demo-snapshot.js' + +/** Demo mode: the e2e harness (and a no-token first look) render the fixed demo + * snapshot through the real view builders — no live gitea, no screen-level mocks. */ +const isDemo = (): boolean => process.env.COMMITEA_E2E === '1' + import { type AppConfig, clearConfig, loadConfig, publicConfig, saveConfig } from './config-store.js' import { loadSnapshot, saveSnapshot } from './snapshot-store.js' @@ -238,12 +253,14 @@ export function registerGiteaIpc(): void { // Instant boot: the last persisted snapshot, shown before the fresh reconcile lands. ipcMain.handle('gitea:boot', () => { + if (isDemo()) return { configured: true, cached: true, ...DEMO_SNAPSHOT, savedAt: DEMO_TODAY } if (!getGiteaClient()) return { configured: false } const persisted = bootSnapshot() return persisted ? { configured: true, cached: true, ...persisted } : { configured: true, cached: false } }) ipcMain.handle('gitea:reconcile', async () => { + if (isDemo()) return { configured: true, stale: false, ...DEMO_SNAPSHOT } const client = getGiteaClient() if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} } try { @@ -313,6 +330,7 @@ export function registerGiteaIpc(): void { // Read the directive ledger from the pm-state repo (for the Directives screen). ipcMain.handle('pmstate:directives', async () => { + if (isDemo()) return { ok: true as const, directives: DEMO_DIRECTIVES } const pm = getPmStateClient() if (!pm) return { ok: false as const, reason: 'unconfigured' as const } try { @@ -324,6 +342,7 @@ export function registerGiteaIpc(): void { // Read the capacity config from the pm-state repo (for capacity-aware forecasts). ipcMain.handle('pmstate:capacity', async () => { + if (isDemo()) return { ok: true as const, members: DEMO_CAPACITY } const pm = getPmStateClient() if (!pm) return { ok: false as const, reason: 'unconfigured' as const, members: [] } try { @@ -335,6 +354,7 @@ export function registerGiteaIpc(): void { // Assignable people (repo collaborators) for the Adjust dialog's assignee picker. ipcMain.handle('gitea:collaborators', async () => { + if (isDemo()) return DEMO_COLLABORATORS const client = getGiteaClient() if (!client) return [] try { @@ -359,6 +379,38 @@ export function registerGiteaIpc(): void { return { ok: true as const } }) + // Discover the owners + repos a token can reach, so onboarding can offer them + // as dropdowns. Token-scoped (not repo-scoped) — no owner/repo needed yet. + ipcMain.handle('config:discover', async (_event, conn: { baseUrl: string; token: string }) => { + try { + const found = await discoverRepos({ baseUrl: conn.baseUrl.replace(/\/+$/, ''), token: conn.token }, fetch) + return { ok: true as const, ...found } + } catch (e) { + return { ok: false as const, error: e instanceof GiteaApiError ? `${e.status}` : e instanceof Error ? e.message : String(e) } + } + }) + + // First-run bootstrap: apply the label schema to the work repo and ensure the + // pm-state sidecar exists. Idempotent — safe to re-run. `underOrg` tells us + // whether `owner` is an org (vs the token's personal namespace). + ipcMain.handle( + 'config:bootstrap', + async (_event, req: { baseUrl: string; token: string; owner: string; repo: string; underOrg: boolean }) => { + try { + const conn = { baseUrl: req.baseUrl.replace(/\/+$/, ''), token: req.token } + const owner = req.owner.trim() + const repo = req.repo.trim() + const client = createGiteaClient({ ...conn, owner, repo }, fetch) + const labels = await applySchemaLabels(client) + const pmStateRepo = `${repo}-pm-state` + const pmState = await ensurePmStateRepo(conn, { owner, repo: pmStateRepo, underOrg: req.underOrg }, fetch) + return { ok: true as const, labels, pmState, pmStateRepo } + } catch (e) { + return { ok: false as const, error: e instanceof GiteaApiError ? `${e.status}` : e instanceof Error ? e.message : String(e) } + } + }, + ) + // Validate a token + repo before saving: any authed read on the repo proves access. ipcMain.handle('config:test', async (_event, cfg: AppConfig) => { try { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index b780fee..f3344e3 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -27,6 +27,10 @@ const api = { config: { /** The saved config (never the token) — null when unset. */ get: () => ipcRenderer.invoke('config:get'), + /** Discover the owners + repos a token can reach (for the onboarding pickers). */ + discover: (conn: unknown) => ipcRenderer.invoke('config:discover', conn), + /** Apply the label schema + ensure the pm-state repo (first-run bootstrap). */ + bootstrap: (req: unknown) => ipcRenderer.invoke('config:bootstrap', req), /** Validate a token + repo before saving. */ test: (cfg: unknown) => ipcRenderer.invoke('config:test', cfg), /** Save config (token encrypted in main); takes effect without restart. */ diff --git a/apps/desktop/src/renderer/src/components/gallery.tsx b/apps/desktop/src/renderer/src/components/gallery.tsx deleted file mode 100644 index ebb2761..0000000 --- a/apps/desktop/src/renderer/src/components/gallery.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import React, { useState } from 'react' - -import { - Badge, - Button, - Card, - Checkbox, - Dialog, - Icon, - IconButton, - Input, - Radio, - Select, - Switch, - Tabs, - Tag, - Toast, - Tooltip, -} from './ui/index.js' - -function Section({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

{title}

-
{children}
-
- ) -} - -/** - * Visual + behavioral proof for the ported primitives (P3-1). Not a product - * screen — the real shell lands in P3-2. Exercises every primitive in both - * themes via the toggle. - */ -export function PrimitivesGallery() { - const [tab, setTab] = useState('board') - const [dialogOpen, setDialogOpen] = useState(false) - const [checked, setChecked] = useState(true) - const [radio, setRadio] = useState('a') - const [on, setOn] = useState(true) - - return ( -
-
-
-

Primitives

-

- 15 components · toggle Evening service in the rail for dark -

-
- -
- - - - - - -
- -
- - - - -
- -
- - ahead - - - at risk - - - behind - - steeping - triage - on track -
- -
- - - - - - - - {}} /> -
- -
- } - footer={ - <> - - - - } - style={{ width: 340 }} - > -

- The tap-root. Everything sinks into it — grab it first. -

-
- -

Hairline border, whispered shadow.

-
-
- -
- -
- -
- - - - - -
- -
- - - - set(e.target.value)} - /> - {opts.hint ? {opts.hint} : null} -
- ) + const placeholder = (label: string) => [{ value: '', label }] return (
vo

- {field('Gitea URL', baseUrl, setBaseUrl, { placeholder: 'https://gitea.example.com' })} + { + setBaseUrl(e.target.value) + setDiscovery('idle') + setFound(null) + }} + /> + n + (found.reposByOwner[o]?.length ?? 0), 0)} repositories across ${found.owners.length} owner${found.owners.length === 1 ? '' : 's'}.` + : 'Paste your token, then tab out to load your repositories.' + } + onChange={(e) => { + setToken(e.target.value) + setDiscovery('idle') + }} + onBlur={() => void discover()} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + void discover() + } + }} + /> +
- {field('Owner', owner, setOwner, { placeholder: 'your-org' })} - {field('Repository', repo, setRepo, { placeholder: 'your-repo' })} + ({ value: r, label: r })) : placeholder('—') + } + onChange={(e) => setRepo(e.target.value)} + />
- {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.', - })} + + setModelUrl(e.target.value)} + />
{error ? ( @@ -108,7 +178,7 @@ export function ConnectScreen({ onConnected, existing }: { onConnected: () => vo
diff --git a/apps/desktop/src/renderer/src/components/screens/deps-graph.tsx b/apps/desktop/src/renderer/src/components/screens/deps-graph.tsx index e7d7ce9..55690d7 100644 --- a/apps/desktop/src/renderer/src/components/screens/deps-graph.tsx +++ b/apps/desktop/src/renderer/src/components/screens/deps-graph.tsx @@ -1,11 +1,15 @@ import React from 'react' -import { DEPS, type DepsData, type IssueRef } from '../../data/fixtures.js' +import { type DepsData, type IssueRef } from '../../data/view-types.js' import { Tag, Icon } from '../ui/index.js' +import { EmptyState } from '../shell/states.js' // Dependency graph drill-in — layered DAG, critical path in spruce export function DepsGraph({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef) => void; data?: DepsData }) { - const g = data ?? DEPS + if (!data || data.nodes.length === 0) { + return + } + const g = data const PAD = 14, COLW = 206, ROWH = 106, NW = 176, NH = 84 const X = (c: number) => PAD + c * COLW @@ -36,7 +40,7 @@ export function DepsGraph({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef diagnosis: { icon: 'circle-dashed', color: 'var(--ink-3)', label: 'diagnosis' }, } - const Node = ({ n }: { n: (typeof DEPS.nodes)[number] }) => { + const Node = ({ n }: { n: DepsData['nodes'][number] }) => { const st = STATES[n.state] const crit = g.critical.includes(n.id) return ( @@ -121,10 +125,6 @@ export function DepsGraph({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef
- -

- Four issues sit on the critical path, and #87 is the cork in the bottle. Remove it and everything pours. -

) } diff --git a/apps/desktop/src/renderer/src/components/screens/directives-screen.tsx b/apps/desktop/src/renderer/src/components/screens/directives-screen.tsx index 7446d4b..304d62d 100644 --- a/apps/desktop/src/renderer/src/components/screens/directives-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/directives-screen.tsx @@ -1,133 +1,124 @@ import React from 'react' -import { DIRECTIVES, type DirectivePending, type DirectiveEntry } from '../../data/fixtures.js' -import { Card, Button, Badge, Icon } from '../ui/index.js' +import { Badge, Card, Icon } from '../ui/index.js' +import { EmptyState } from '../shell/states.js' -// Directive log — append-only ledger + the consequence diff (propose-approve) +/** A ledger row, projected from a live DirectiveRecord. */ +interface LedgerEntry { + seq: number + when: string + what: string + why: string + kind: string + status: 'applied' | 'amended' | 'withdrawn' | 'proposed' +} + +const STATUS_BADGE: Record = { + applied: { tone: 'ok', label: 'applied' }, + amended: { tone: 'info', label: 'amended' }, + withdrawn: { tone: 'neutral', label: 'withdrawn' }, + proposed: { tone: 'info', label: 'proposed' }, +} + +/** + * Directive ledger — the append-only record of every time a human overruled the + * scheduler (who/when/what/why), read live from the pm-state repo. Directives are + * given in chat and recorded here; this screen only reads them. + */ export function DirectivesScreen() { - const [pending, setPending] = React.useState(DIRECTIVES.pending) - const [entries, setEntries] = React.useState(DIRECTIVES.entries) + const [entries, setEntries] = React.useState(null) // null = loading - // Real ledger from the pm-state repo, when it exists — else the fixture demo. React.useEffect(() => { let alive = true window.commitea.pmstate .directives() .then((r) => { - if (!alive || !r.ok || r.directives.length === 0) return - setPending(null) + if (!alive) return + if (!r.ok) { + setEntries([]) + return + } setEntries( r.directives .slice() - .reverse() + .reverse() // newest first .map((d) => ({ seq: d.seq, - who: 'You', when: new Date(d.ts).toLocaleDateString(), what: d.quote, why: d.rationale ?? '', - status: d.status === 'accepted' ? 'applied' : d.status === 'withdrawn' ? 'withdrawn' : 'superseded', - consequence: '', + kind: d.kind, + status: + d.status === 'accepted' + ? 'applied' + : d.status === 'amended' + ? 'amended' + : d.status === 'withdrawn' + ? 'withdrawn' + : 'proposed', })), ) }) - .catch(() => {}) + .catch(() => { + if (alive) setEntries([]) + }) return () => { alive = false } }, []) - const resolve = (status: string) => { - setEntries((e) => [{ - seq: pending!.seq, who: pending!.who, when: pending!.when, what: pending!.what, why: 'pilot demo on the 14th', - status, consequence: status === 'applied' ? '#78 +5d · Beta 80% Mar 5–14' : 'withdrawn before apply', - }, ...e]); - setPending(null); - }; - - const toneColor: Record = { ok: 'var(--ok)', warn: 'var(--warn)', info: 'var(--info)', danger: 'var(--danger)' }; - const statusBadge: Record = { - applied: { tone: 'ok', label: 'applied' }, - withdrawn: { tone: 'neutral', label: 'withdrawn' }, - superseded: { tone: 'info', label: 'superseded' }, - }; - return (

Directives

-

append-only · JSONL in pm-state · who, when, what, why

+

+ append-only · JSONL in pm-state · who, when, what, why +

- {pending ? ( - - - - - }> -
-

- {pending.who} - · {pending.when} -
“{pending.what}” -

-
- {pending.diff.map((r) => ( -
- - {r.change} - - {r.from} {r.to} - + {entries === null ? null : entries.length === 0 ? ( + + ) : ( + +
+ {entries.map((e, i) => { + const sb = STATUS_BADGE[e.status] + return ( +
+
+ #{String(e.seq).padStart(3, '0')} + +
+
+
+ {e.kind} + {e.when} + {e.why ? ( + · why: {e.why} + ) : null} + + {sb.label} + +
+

“{e.what}”

+
- ))} -
-

- Cheap, as consequences go. Shall I make it so? -

+ ) + })}
- ) : ( -
- - Nothing awaits your word. Directives are given in chat; consequences appear here first. -
)} - -
- {entries.map((e, i) => { - const sb = statusBadge[e.status]; - return ( -
-
- #00{e.seq} - -
-
-
- {e.who.split(' ').map((w: string) => w[0]).join('')} - {e.who} - {e.when} - {e.why ? · why: {e.why} : null} - {sb.label} -
-

“{e.what}”

-

{e.consequence}

-
-
- ); - })} -
-
-

Entries are never edited. Corrections are new entries — the ledger remembers everything, politely.

- ); + ) } diff --git a/apps/desktop/src/renderer/src/components/screens/focus-screen.tsx b/apps/desktop/src/renderer/src/components/screens/focus-screen.tsx index 41d81c8..6798486 100644 --- a/apps/desktop/src/renderer/src/components/screens/focus-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/focus-screen.tsx @@ -1,8 +1,9 @@ import React from 'react' -import { FOCUS, type FocusIssue, type IssueRef, TODAY } from '../../data/fixtures.js' +import { type FocusIssue, type IssueRef } from '../../data/view-types.js' import { type ForecastView, type FocusView } from '../../lib/backlog.js' import { BurnUpCone } from '../charts/chart.js' +import { EmptyState } from '../shell/states.js' import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js' /** @@ -19,7 +20,6 @@ export function FocusScreen({ focus?: FocusView forecast?: ForecastView }) { - const view: FocusView = focus ?? { now: FOCUS.now, next: FOCUS.next, later: FOCUS.later } const FocusRow = ({ slot, issue, jade }: { slot: string; issue: FocusIssue; jade?: boolean }) => ( ) + if (!focus) { + return ( + + ) + } + return (

Morning service

-

- {TODAY} · reconcile 3.2s -

- - ahead of forecast -
- {view.now ? : null} - {view.next ? : null} - {view.later ? : null} + {focus.now ? : null} + {focus.next ? : null} + {focus.later ? : null}
- 80% of the open backlog lands by {forecast.rangeLabel} - ) : ( - <>80% this lands Mar 3–12 - ) - } - actions={} - > - -

- {forecast - ? forecast.coldStart + } + actions={} + > + +

+ {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, calibrated on ${forecast.calibratedN} closed ${forecast.calibratedN === 1 ? 'issue' : 'issues'} of your own.` - : 'The cone has narrowed since Friday. I’m quietly pleased.'} -

-
+ : `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope, calibrated on ${forecast.calibratedN} closed ${forecast.calibratedN === 1 ? 'issue' : 'issues'} of your own.`} +

+ + ) : null}
) } diff --git a/apps/desktop/src/renderer/src/components/screens/gantt-view.tsx b/apps/desktop/src/renderer/src/components/screens/gantt-view.tsx index 52908cc..1de4a3b 100644 --- a/apps/desktop/src/renderer/src/components/screens/gantt-view.tsx +++ b/apps/desktop/src/renderer/src/components/screens/gantt-view.tsx @@ -1,11 +1,15 @@ import React from 'react' -import { GANTT, type GanttData, type IssueRef } from '../../data/fixtures.js' +import { type GanttData, type IssueRef } from '../../data/view-types.js' +import { EmptyState } from '../shell/states.js' import { Icon } from '../ui/index.js' // Gantt drill-in — scheduler-derived bars, critical chain, 80% forecast tails export function GanttView({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef) => void; data?: GanttData }) { - const g = data ?? GANTT; + if (!data || data.rows.length === 0) { + return ; + } + const g = data; const LABELW = 232, DAYW = 21, ROWH = 36, HEADH = 30; const chartW = g.days * DAYW; @@ -96,10 +100,6 @@ export function GanttView({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef })}
- -

- The path holds if #87 lands by Wednesday. The dotted tails are your own history, wagging. -

); } diff --git a/apps/desktop/src/renderer/src/components/screens/inbox-screen.tsx b/apps/desktop/src/renderer/src/components/screens/inbox-screen.tsx index 7079862..0a25f04 100644 --- a/apps/desktop/src/renderer/src/components/screens/inbox-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/inbox-screen.tsx @@ -1,6 +1,7 @@ import React from 'react' -import { INBOX, type InboxItem, type IssueRef } from '../../data/fixtures.js' +import { type InboxItem, type IssueRef } from '../../data/view-types.js' +import { EmptyState } from '../shell/states.js' import { Button, Card, Icon, Tabs } from '../ui/index.js' // Inbox — Reginald only rings the bell when it matters @@ -17,9 +18,14 @@ export function InboxScreen({ setReadIds: React.Dispatch> data?: InboxItem[] }) { - const all = data ?? INBOX const [tab, setTab] = React.useState('all') + if (!data || data.length === 0) { + return + } + + const all = data + const isRead = (n: InboxItem) => !n.unread || readIds.includes(n.id) const unreadCount = all.filter((n) => !isRead(n)).length diff --git a/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx b/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx index b1e2685..92f7ec8 100644 --- a/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx @@ -10,7 +10,7 @@ import { summarizeChange, } from '@commitea/core' -import { ISSUE_DETAIL, type IssueDetail, type IssueRef } from '../../data/fixtures.js' +import { type IssueDetail, type IssueRef } from '../../data/view-types.js' import { Badge, Button, Card, Dialog, Icon, Select, Tag } from '../ui/index.js' const NONE = '—' @@ -38,7 +38,7 @@ export function IssueScreen({ currentAssignee?: string | null currentMilestoneId?: number | null }) { - const det: IssueDetail = detail ?? ISSUE_DETAIL[issue.id] ?? { + const det: IssueDetail = detail ?? { state: 'triage', assignee: 'Stephen', milestone: 'Beta', diff --git a/apps/desktop/src/renderer/src/components/screens/milestone-screen.tsx b/apps/desktop/src/renderer/src/components/screens/milestone-screen.tsx index 9348dbc..7553dec 100644 --- a/apps/desktop/src/renderer/src/components/screens/milestone-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/milestone-screen.tsx @@ -1,12 +1,12 @@ import React from 'react' -import { COLUMNS, type IssueRef } from '../../data/fixtures.js' +import { type IssueRef } from '../../data/view-types.js' import { type MilestoneView } from '../../lib/backlog.js' import { BurnUpCone } from '../charts/chart.js' +import { EmptyState } from '../shell/states.js' import { Badge, Button, Card, Icon, Tag } from '../ui/index.js' // Milestone detail — scope, cone, issues; forecasts stay ranges. -// `data` (real milestone forecast) overrides the demo fixture when present. export function MilestoneScreen({ onBack, onOpenIssue, @@ -16,28 +16,24 @@ export function MilestoneScreen({ onOpenIssue: (issue: IssueRef) => void data?: MilestoneView }) { - const cols = COLUMNS - const byState = (ids: number[]) => - cols.flatMap((c) => c.issues.map((i) => ({ ...i, col: c.label }))).filter((i) => ids.includes(i.id)) + if (!data) { + return ( + + ) + } - const fixtureGroups = [ - { label: 'Steeping', issues: byState([87, 84]) }, - { label: 'In review', issues: byState([92]) }, - { label: 'Queued', issues: byState([102, 103, 99, 96, 78]) }, - { label: 'Done', issues: byState([71, 69, 65]), muted: true }, - ] - - // real or demo, in one shape the render loop understands - const groups = data - ? data.groups.map((g) => ({ label: g.label, issues: g.issues, muted: g.label === 'Done' })) - : fixtureGroups - const name = data ? data.name : 'Beta' - const dueLine = data - ? `milestone · due ${data.due} · ${data.soft ? 'soft — scope may flex' : 'hard deadline'}` - : 'milestone · due Mar 15 · soft — scope may flex' - const forecastLabel = data ? (data.forecastRange ? `80% ${data.forecastRange}` : 'all shipped') : '80% Mar 3–12' - const scopeStat = data ? `${data.scopeCount} issues · est ${data.scopeEstDays}d` : '42 issues · est 61d' - const doneStat = data ? `${data.doneCount} · ${data.donePct}%` : '24 · 57%' + const groups = data.groups.map((g) => ({ label: g.label, issues: g.issues, muted: g.label === 'Done' })) + const name = data.name + const dueLine = `milestone · due ${data.due} · ${data.soft ? 'soft — scope may flex' : 'hard deadline'}` + const forecastLabel = data.forecastRange ? `80% ${data.forecastRange}` : 'all shipped' + const scopeStat = `${data.scopeCount} issues · est ${data.scopeEstDays}d` + const doneStat = `${data.doneCount} · ${data.donePct}%` const Stat = ({ label, value, tone }: { label: string; value: string; tone?: string }) => (
@@ -62,7 +58,7 @@ export function MilestoneScreen({

{name}

- {data ? `${data.doneCount}/${data.scopeCount} done` : 'ahead of forecast'} + {`${data.doneCount}/${data.scopeCount} done`} {forecastLabel}
@@ -78,20 +74,18 @@ export function MilestoneScreen({
Remaining
-
{data ? `${data.scopeCount - data.doneCount} open` : '−2d · cone narrowed'}
+
{`${data.scopeCount - data.doneCount} open`}
- 80% this lands {data ? (data.forecastRange ?? 'shipped') : 'Mar 3–12'}} jade> - + 80% this lands {data.forecastRange ?? 'shipped'}} jade> +

- {data - ? data.cone - ? `${data.scopeCount - data.doneCount} open of ${data.scopeCount}. Cone over what's left.` - : 'Everything here has shipped.' - : 'Comfortably ahead. Beta needs #87 more than it needs my commentary.'} + {data.cone + ? `${data.scopeCount - data.doneCount} open of ${data.scopeCount}. Cone over what's left.` + : 'Everything here has shipped.'}

diff --git a/apps/desktop/src/renderer/src/components/screens/onboarding-screen.tsx b/apps/desktop/src/renderer/src/components/screens/onboarding-screen.tsx index 6e20ad9..71048ae 100644 --- a/apps/desktop/src/renderer/src/components/screens/onboarding-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/onboarding-screen.tsx @@ -1,51 +1,121 @@ import React from 'react' +import type { BootstrapResult, DiscoverResult } from '../../global.js' import logoIcon from '../../design/assets/logo-icon.png' -import { Badge, Button, Icon, Input, Radio, Tag } from '../ui/index.js' +import { Badge, Button, Icon, Input, Radio, Select, Tag } from '../ui/index.js' -// Onboarding / first connect — welcome → connect gitea → choose repo → bootstrap -export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture') => void }) { - const [step, setStep] = React.useState(0) - const [conn, setConn] = React.useState<'idle' | 'testing' | 'ok'>('idle') - const [repo, setRepo] = React.useState('stephen/commitea') - const [boot, setBoot] = React.useState(-1) // -1 idle, 0..2 running, 3 done +/** + * First-run onboarding — a real, live wizard. Welcome → Connect (live token + * discovery) → Repo (pick from the repos that token can actually reach) → + * Bootstrap (apply the label schema + create the pm-state sidecar, for real), + * then into the app. Every step talks to the main-process bridge; nothing here + * is faked. The token is saved encrypted in main only after a successful bootstrap. + */ +export function OnboardingScreen({ onConnected }: { onConnected: (dest: 'focus' | 'capture') => void }) { + const [step, setStep] = React.useState(0) - React.useEffect(() => { - if (conn !== 'testing') return - const t = setTimeout(() => setConn('ok'), 1100) - return () => clearTimeout(t) - }, [conn]) + // connect step + const [baseUrl, setBaseUrl] = React.useState('https://gitea.stephenmann.io') + const [token, setToken] = React.useState('') + const [discovery, setDiscovery] = React.useState<'idle' | 'testing' | 'ok'>('idle') + const [found, setFound] = React.useState<{ user: string; owners: string[]; reposByOwner: Record } | null>(null) + const [error, setError] = React.useState(null) - React.useEffect(() => { - if (boot < 0 || boot >= 3) return - const t = setTimeout(() => setBoot(boot + 1), 700) - return () => clearTimeout(t) - }, [boot]) + // repo step + const [owner, setOwner] = React.useState('') + const [repo, setRepo] = React.useState('') + + // bootstrap step + const [boot, setBoot] = React.useState<'idle' | 'running' | 'done'>('idle') + const [bootResult, setBootResult] = React.useState | null>(null) + + const repoCount = found ? found.owners.reduce((n, o) => n + (found.reposByOwner[o]?.length ?? 0), 0) : 0 + + const test = async () => { + if (!baseUrl.trim() || !token.trim() || discovery === 'testing') return + setError(null) + setDiscovery('testing') + const res: DiscoverResult = await window.commitea.config + .discover({ baseUrl: baseUrl.trim(), token: token.trim() }) + .catch(() => ({ ok: false as const, error: 'unreachable' })) + if (!res.ok) { + setDiscovery('idle') + setFound(null) + setError( + res.error === '401' || res.error === '403' + ? 'The token was rejected — check it has repo + issue scopes.' + : res.error === '404' + ? "Couldn't reach that Gitea — check the URL." + : `Connection failed${res.error ? ` (${res.error})` : ''}.`, + ) + return + } + setFound({ user: res.user, owners: res.owners, reposByOwner: res.reposByOwner }) + setDiscovery('ok') + const o = res.owners.includes(res.user) ? res.user : res.owners[0] ?? '' + setOwner(o) + setRepo(res.reposByOwner[o]?.[0] ?? '') + } + + const runBootstrap = async () => { + if (!found || boot === 'running') return + setError(null) + setBoot('running') + const res = await window.commitea.config + .bootstrap({ baseUrl: baseUrl.trim(), token: token.trim(), owner, repo, underOrg: owner !== found.user }) + .catch(() => ({ ok: false as const, error: 'unreachable' })) + if (!res.ok) { + setBoot('idle') + setError(`Bootstrap failed${res.error ? ` (${res.error})` : ''}. Nothing was half-applied — you can retry.`) + return + } + // persist the connection now that the repo is prepared + await window.commitea.config.set({ baseUrl: baseUrl.trim(), owner, repo, token: token.trim() }) + setBootResult(res) + setBoot('done') + } const STEPS = ['Welcome', 'Connect', 'Repo', 'Bootstrap'] - const BOOT_TASKS = [ - 'Create stephen/pm-state (the sidecar)', - 'Apply the label schema to stephen/commitea', - 'Install a webhook · endpoint :48731', - ] + const repoOptions = found?.reposByOwner[owner] ?? [] const Frame = ({ children, footer }: { children: React.ReactNode; footer?: React.ReactNode }) => ( -
+
{children} - {footer ?
{footer}
: null} + {error ?

{error}

: null} + {footer ? ( +
+ {footer} +
+ ) : null}
) return ( -
- {/* brand */} +
@@ -58,15 +128,24 @@ export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture {STEPS.map((s, i) => (
- {i < step ? '✓' : i + 1} - {s} + + {i < step ? '✓' : i + 1} + + + {s} + - {i < STEPS.length - 1 ? : null} + {i < STEPS.length - 1 ? : null}
))}
@@ -76,8 +155,8 @@ export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture setStep(1)}>Begin}>

Good morning.

- I'm Reginald, your project manager. I interview you instead of making you fill in forms, - I forecast in honest ranges, and I never do the arithmetic myself — there's a scheduler for that. + I'm Reginald, your project manager. I interview you instead of making you fill in forms, I forecast in + honest ranges, and I never do the arithmetic myself — there's a scheduler for that.

Your plans live in your own Gitea as ordinary issues and labels. Delete me and nothing human is lost. @@ -86,79 +165,196 @@ export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture ) : null} {step === 1 ? ( - - - - }> + + + + + } + >

Your Gitea

- - + { + setBaseUrl(e.target.value) + setDiscovery('idle') + setFound(null) + }} + /> + { + setToken(e.target.value) + setDiscovery('idle') + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + void test() + } + }} + />
- - {conn === 'ok' ? connected · 3 repos visible : null} + {discovery === 'ok' ? ( + + connected · {repoCount} repo{repoCount === 1 ? '' : 's'} visible + + ) : null}
) : null} {step === 2 ? ( - - - - }> + + + + + } + >

Which repo shall I manage?

-
- {['stephen/commitea', 'stephen/novelpad', 'stephen/infra'].map((r: string) => ( - - ))} + {found && found.owners.length > 1 ? ( +