diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b0428ea --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +GITEA_TOKEN= diff --git a/.gitignore b/.gitignore index 5065492..08c279b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,15 @@ dist/ out/ *.log .DS_Store + +# playwright e2e artifacts +.artifacts/ +test-results/ +playwright-report/ +.last-run.json .env +.env.* +!.env.example .yarn/* !.yarn/patches !.yarn/plugins diff --git a/README.md b/README.md index 4ef66b1..c9cfa86 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,20 @@ yarn test # unit tests (vitest) yarn typecheck ``` +### End-to-end (Electron + Playwright) + +```sh +yarn workspace @commitea/desktop e2e # build, then drive the built app +yarn workspace @commitea/desktop e2e:only # reuse existing out/ build (tight loop) +yarn workspace @commitea/desktop e2e:report # open the last HTML report +``` + +Tests launch the built app (`out/main/index.js`) through Playwright's +`_electron` API — no browser project, no chromium download. Fixtures and page +objects live in `apps/desktop/e2e/`; screenshots land in +`e2e/.artifacts/screens/` for visual review. Page objects use user-facing +locators (`getByRole`/`getByText`), never CSS/DOM structure. + ## Conventions - Yarn 4 workspaces; ESM everywhere; `.js` extensions on relative imports diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts new file mode 100644 index 0000000..1c65e4f --- /dev/null +++ b/apps/desktop/e2e/fixtures.ts @@ -0,0 +1,48 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + _electron as electron, + expect, + test as base, + type ElectronApplication, + type Page, +} from '@playwright/test' + +import { AppPage } from './pages/app.page.js' + +const here = dirname(fileURLToPath(import.meta.url)) +/** The built main-process entry Playwright launches. `yarn e2e` builds it first. */ +const MAIN_ENTRY = join(here, '..', 'out', 'main', 'index.js') + +interface CommiteaFixtures { + /** The launched Electron application. */ + electronApp: ElectronApplication + /** The app's first (and only) BrowserWindow, as a Playwright Page. */ + window: Page + /** Page Object over the app shell. */ + app: AppPage +} + +export const test = base.extend({ + electronApp: async ({}, use) => { + const electronApp = await electron.launch({ + args: [MAIN_ENTRY], + env: { ...process.env, NODE_ENV: 'test', COMMITEA_E2E: '1' }, + }) + await use(electronApp) + await electronApp.close() + }, + + window: async ({ electronApp }, use) => { + const window = await electronApp.firstWindow() + await window.waitForLoadState('domcontentloaded') + await use(window) + }, + + app: async ({ window }, use) => { + await use(new AppPage(window)) + }, +}) + +export { expect } diff --git a/apps/desktop/e2e/pages/app.page.ts b/apps/desktop/e2e/pages/app.page.ts new file mode 100644 index 0000000..3bffb25 --- /dev/null +++ b/apps/desktop/e2e/pages/app.page.ts @@ -0,0 +1,50 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { expect, type Locator, type Page } from '@playwright/test' + +const here = dirname(fileURLToPath(import.meta.url)) +/** Where `screenshot()` drops PNGs — gitignored, readable for visual review. */ +const SCREENS_DIR = join(here, '..', '.artifacts', 'screens') + +/** + * Page Object over the CommiTea app shell. As real screens land (P3), add + * per-screen page objects and accessor methods here; keep locators + * user-facing (getByRole/getByText), never CSS/DOM structure. + */ +export class AppPage { + constructor(readonly page: Page) {} + + /** The rail wordmark — present on every in-app view. */ + get wordmark(): Locator { + return this.page.getByText('CommiTea', { exact: true }) + } + + /** Navigate via a left-rail entry by its label. Scoped to the Primary rail so + * it never collides with in-screen breadcrumb buttons of the same name, and + * matches by prefix so count badges (e.g. "Inbox 3") still resolve. */ + nav(label: string): Locator { + return this.page.getByRole('navigation', { name: 'Primary' }).getByRole('button', { name: label }) + } + + /** Assert the shell has rendered. */ + async expectLoaded(): Promise { + await expect(this.wordmark).toBeVisible() + } + + /** Read the `commitea` preload API surface from the renderer. */ + async preloadApi(): Promise | undefined> { + return this.page.evaluate( + () => (globalThis as unknown as { commitea?: Record }).commitea, + ) + } + + /** Capture a full-page screenshot for visual review; returns the path. + * `animations: 'disabled'` fast-forwards finite CSS animations to their end + * state, so fade-in screens (e.g. Standup) capture settled, not mid-fade. */ + async screenshot(name: string): Promise { + const path = join(SCREENS_DIR, `${name}.png`) + await this.page.screenshot({ path, fullPage: true, animations: 'disabled' }) + return path + } +} diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts new file mode 100644 index 0000000..eff9879 --- /dev/null +++ b/apps/desktop/e2e/smoke.spec.ts @@ -0,0 +1,146 @@ +import { expect, test } from './fixtures.js' + +test('boots into the shell with the Focus screen', async ({ app, window }) => { + await app.expectLoaded() + await expect(window.getByText('Reginald', { exact: true })).toBeVisible() + await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible() + await app.screenshot('shell-light') +}) + +test('Focus shows Now/Next/Later cards and the burn-up cone', 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() +}) + +test('Standup renders the drift / plan / nag 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 app.screenshot('standup') +}) + +test('Board: columns, search filter, and Gantt/Deps tabs', 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() + await expect(window.getByText('Steeping', { exact: true })).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('') + + 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 }) => { + 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 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 }) => { + 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 }) => { + 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 }) => { + 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() + 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 }) => { + await app.nav('Settings').click() + await expect(window.getByRole('heading', { name: 'Settings' })).toBeVisible() + await expect(window.getByText('Managed repos')).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() + await app.screenshot('issue') + await window.getByRole('button', { name: 'Back' }).click() + await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible() +}) + +test('Evening service toggle flips the document theme', async ({ app, window }) => { + await window.getByText('Evening service', { exact: true }).click() + await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark') + 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() + expect(api).toHaveProperty('platform') +}) diff --git a/apps/desktop/e2e/tsconfig.json b/apps/desktop/e2e/tsconfig.json new file mode 100644 index 0000000..ffa38d1 --- /dev/null +++ b/apps/desktop/e2e/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node"], + "moduleResolution": "Bundler", + "noEmit": true + }, + "include": ["."] +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5f128b0..f2e04af 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -7,7 +7,10 @@ "dev": "electron-vite dev 2>&1 | tee desktop.log", "build": "electron-vite build", "start": "electron-vite preview", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "e2e": "electron-vite build && playwright test", + "e2e:only": "playwright test", + "e2e:report": "playwright show-report e2e/.artifacts/report" }, "dependencies": { "@commitea/core": "workspace:*", @@ -15,6 +18,7 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@types/node": "^22.13.1", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts new file mode 100644 index 0000000..f6ec82a --- /dev/null +++ b/apps/desktop/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from '@playwright/test' + +/** + * E2E harness for the Electron app. Tests launch the *built* app + * (`out/main/index.js`) through Playwright's `_electron` API — see + * `e2e/fixtures.ts`. There is no browser project and no chromium download: + * `_electron` drives the app's own bundled electron. + * + * Run `yarn e2e` (builds first) for a fresh run, or `yarn e2e:only` to reuse + * the existing `out/` build during a tight iteration loop. + */ +export default defineConfig({ + testDir: './e2e', + outputDir: './e2e/.artifacts/test-results', + fullyParallel: false, + workers: 1, // one electron instance at a time — deterministic, avoids window races + forbidOnly: !!process.env.CI, + retries: 0, + timeout: 30_000, + expect: { timeout: 5_000 }, + reporter: [['list'], ['html', { outputFolder: './e2e/.artifacts/report', open: 'never' }]], + use: { + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, +}) diff --git a/apps/desktop/src/renderer/src/app.tsx b/apps/desktop/src/renderer/src/app.tsx index ef35ec2..64be805 100644 --- a/apps/desktop/src/renderer/src/app.tsx +++ b/apps/desktop/src/renderer/src/app.tsx @@ -1,26 +1,5 @@ -import { extractLabelFacts } from '@commitea/core' - -const facts = extractLabelFacts(['est/3d', 'p/2', 'deadline/hard']) +import { AppShell } from './components/shell/app-shell.js' export function App() { - return ( -
-
-
-

CommiTea

-

- scaffold · phase 0 · gitea connection pending -

-
-

- Good morning. The scaffold stands and the kettle is on, but I have nothing to manage - yet. Connect me to gitea and we shall put the pot to work. -

-

- label parse check: est/{facts.estimateDays}d · p/{facts.priority} · hard{' '} - {String(facts.hardDeadline)} -

-
-
- ) + return } diff --git a/apps/desktop/src/renderer/src/components/charts/chart.tsx b/apps/desktop/src/renderer/src/components/charts/chart.tsx new file mode 100644 index 0000000..a9c16fb --- /dev/null +++ b/apps/desktop/src/renderer/src/components/charts/chart.tsx @@ -0,0 +1,121 @@ +/** + * Charts — geometry, not decoration. Ported from the handoff's Chart.js. The + * fixed sample paths here stand in for scheduler/Monte Carlo output (P2); the + * shapes (cone from today, 80% band, actual polyline, today rule) are final. + */ + +export interface BurnUpConeProps { + width?: number + height?: number +} + +export function BurnUpCone({ width = 640, height = 220 }: BurnUpConeProps) { + const pad = { l: 34, r: 96, t: 16, b: 26 } + const W = width - pad.l - pad.r + const H = height - pad.t - pad.b + const x = (f: number) => pad.l + f * W + const y = (f: number) => pad.t + (1 - f) * H + + const today = 0.58 + const actual: number[][] = [ + [0, 0], + [0.08, 0.05], + [0.18, 0.13], + [0.26, 0.16], + [0.36, 0.27], + [0.46, 0.38], + [0.58, 0.47], + ] + const coneHi: number[][] = [ + [0.58, 0.47], + [0.72, 0.66], + [0.86, 0.88], + [0.95, 1.0], + ] + const coneLo: number[][] = [ + [0.58, 0.47], + [0.74, 0.58], + [0.9, 0.74], + [1.0, 0.86], + ] + const mid: number[][] = [ + [0.58, 0.47], + [0.76, 0.63], + [0.92, 0.83], + [1.0, 0.93], + ] + const pts = (arr: number[][]) => arr.map(([a, b]) => `${x(a)},${y(b)}`).join(' ') + const cone = [...coneHi, ...[...coneLo].reverse()] + + return ( + + {/* gridlines */} + {[0, 0.25, 0.5, 0.75, 1].map((f) => ( + + ))} + {/* scope */} + + + scope · 42 issues + + {/* cone */} + + + + + {/* actual */} + + + {/* today rule */} + + + today + + {/* 80% band label */} + + 80% + + + Mar 3–12 + + {/* x labels */} + + Jan 6 + + + Mar 15 + + + ) +} + +export interface RunwayBarMilestone { + tone: 'ok' | 'warn' + pos: number + spread: number +} + +/** Milestone due-date vs forecast-range position. Used by the Runway screen (P3-5). */ +export function RunwayBar({ m }: { m: RunwayBarMilestone }) { + const toneColor = m.tone === 'warn' ? 'var(--warn)' : 'var(--ok)' + const left = Math.max(0, (m.pos - m.spread / 2) * 100) + const w = Math.min(100 - left, m.spread * 100) + return ( +
+
+
+
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/gallery.tsx b/apps/desktop/src/renderer/src/components/gallery.tsx new file mode 100644 index 0000000..ebb2761 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/gallery.tsx @@ -0,0 +1,211 @@ +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.

+
+
+ +
+ +
+ +
+ + + + + +
+ +
+ + + + ) => setQuery(e.target.value)} />
+ + + {tab === 'board' ? ( + anyMatch ? ( +
+ {filtered.map((col) => ( +
+
+ {col.label} + {col.issues.length} +
+ {col.issues.map((i) => )} +
+ ))} +
+ ) : ( +
+ n + c.issues.length, 0)} issues; none of them answer to “${query.trim()}”.`} /> +
+ ) + ) : tab === 'deps' ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/screens/calibration-screen.tsx b/apps/desktop/src/renderer/src/components/screens/calibration-screen.tsx new file mode 100644 index 0000000..a3081dc --- /dev/null +++ b/apps/desktop/src/renderer/src/components/screens/calibration-screen.tsx @@ -0,0 +1,180 @@ +import React from 'react' + +import { CALIBRATION } from '../../data/fixtures.js' +import { Badge, Card, Icon } from '../ui/index.js' + +// Calibration report — estimate-vs-actual evidence behind the cones +export function CalibrationScreen({ onBack }: { onBack: () => void }) { + const c = CALIBRATION + + // scatter chart geometry + const W = 420, + H = 300, + pad = { l: 36, r: 16, t: 14, b: 30 } + const maxD = 9 + const X = (d: number) => pad.l + (d / maxD) * (W - pad.l - pad.r) + const Y = (d: number) => H - pad.b - (d / maxD) * (H - pad.t - pad.b) + + const BiasBar = ({ bias }: { bias: number | null }) => { + if (bias == null) + return n too small + return ( +
+
+
+
15 ? 'var(--warn)' : 'var(--ok)', + borderRadius: '0 3px 3px 0', + opacity: 0.75, + }} + >
+
+ 15 ? 'var(--warn)' : 'var(--ok)', width: 42, textAlign: 'right' }}> + +{bias}% + +
+ ) + } + + return ( +
+
+ +
+
+

Calibration

+

{c.n} closed issues with estimates · evidence, not opinion

+
+ curve active · n ≥ 20 +
+
+ +
+ {/* scatter */} + + + {[1, 3, 5, 8].map((d) => ( + + + {d}d + + {d}d + + ))} + {/* perfect line */} + + honest + {/* fit */} + + you · ×{c.fit} + {/* points */} + {c.scatter.map(([e, a], i) => ( + + ))} + +

estimated (x) vs actual days (y) · actuals inferred from git events, never tracked

+
+ +
+ {/* per-label bias */} + +
+ {c.labels.map((r, i) => ( +
+ {r.label} + n={r.n} · {r.median} + +
+ ))} +
+
+ + {/* per-person */} + +
+ {c.people.map((p, i) => ( +
+ + {p.who + .split(' ') + .map((w) => w[0]) + .join('')} + +
+ {p.who} + · n={p.n} · {p.note} +
+ 15 ? 'var(--warn)' : 'var(--ok)' }}>+{p.bias}% +
+ ))} +
+
+ + {/* effect on forecasts */} + +
+ {c.effect.raw} + + {c.effect.banded} +
+

+ You are not bad at estimating; you are optimistic in a very stable way. Stable, I can work with. +

+
+
+
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/screens/capture-screen.tsx b/apps/desktop/src/renderer/src/components/screens/capture-screen.tsx new file mode 100644 index 0000000..6d0c71d --- /dev/null +++ b/apps/desktop/src/renderer/src/components/screens/capture-screen.tsx @@ -0,0 +1,209 @@ +import React from 'react' + +import { Badge, Button, Card, Icon, Select, Tag } from '../ui/index.js' + +// Capture interview — braindump → interview → approved ticket set (< 2 min) + +interface Ticket { + title: string + est: string + p: string + dep?: string + byReginald?: boolean +} + +interface Question { + q: string + chips: string[] + set: (a: string) => void +} + +export function CaptureScreen({ onDone }: { onDone: () => void }) { + const [stage, setStage] = React.useState<'dump' | 'interview' | 'review' | 'filed'>('dump') + const [dump, setDump] = React.useState( + 'auth is flaky — token refresh dies silently, sometimes session storage goes stale. ' + + 'also the webhook debounce thing keeps double-firing. and we owe docs for auth setup' + ) + const [qi, setQi] = React.useState(0) + const [log, setLog] = React.useState<{ q: string; a: string }[]>([]) + const [split, setSplit] = React.useState(null) + const [webEst, setWebEst] = React.useState(null) + const [secs, setSecs] = React.useState(0) + + const running = stage === 'interview' || stage === 'review' + React.useEffect(() => { + if (!running) return + const t = setInterval(() => setSecs((s) => s + 1), 1000) + return () => clearInterval(t) + }, [running]) + const clock = `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}` + + const QUESTIONS: Question[] = [ + { + q: 'The auth work — one ticket, or shall I split token refresh from session storage? They fail differently.', + chips: ['One ticket', 'Split them'], + set: (a) => setSplit(a === 'Split them'), + }, + { + q: 'The webhook double-fire — how long? I should mention your "quick" has averaged two days.', + chips: ['est/1d', 'est/2d', 'est/3d'], + set: (a) => setWebEst(a), + }, + { + q: 'Milestone Beta, I presume? It has room, provided the auth work stays under four days.', + chips: ['Beta', 'New milestone'], + set: () => {}, + }, + ] + + const answer = (a: string) => { + QUESTIONS[qi].set(a) + setLog((l) => [...l, { q: QUESTIONS[qi].q, a }]) + if (qi + 1 < QUESTIONS.length) setQi(qi + 1) + else setStage('review') + } + + // draft tickets build as the interview progresses + const tickets: Ticket[] = [] + if (split === true) { + tickets.push({ title: 'Token refresh: retry with backoff', est: 'est/2d', p: 'p/2' }) + tickets.push({ title: 'Session storage: stale reads on wake', est: 'est/1d', p: 'p/3' }) + } else if (split === false) { + tickets.push({ title: 'Auth: token refresh + session storage', est: 'est/3d', p: 'p/2' }) + } + if (webEst) tickets.push({ title: 'Webhook debounce: double-fire guard', est: webEst, p: 'p/1', dep: 'blocked by auth work' }) + if (stage === 'review' || stage === 'filed') { + tickets.push({ title: 'Docs: auth setup guide', est: 'est/1d', p: 'p/4', byReginald: true }) + } + const totalDays = tickets.reduce((n, t) => n + parseInt(t.est.replace('est/', '')), 0) + + const estOptions = ['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d'].map((v) => ({ value: v, label: v })) + const pOptions = ['p/1', 'p/2', 'p/3', 'p/4'].map((v) => ({ value: v, label: v })) + + const Tray = ({ editable }: { editable?: boolean }) => ( + 1 ? 's' : ''}` : 'Empty, for now'} flush> +
+ {tickets.length === 0 ? ( +

+ Tickets appear here as we talk. +

+ ) : tickets.map((t, i) => ( +
+
{t.title}
+
+ {editable ? ( + <> + + + ) : ( + <> + + + + )} + {t.dep ? {t.dep} : null} + {t.byReginald ? added by Reginald : null} +
+
+ ))} +
+
+ ) + + return ( +
+
+
+

Capture

+

braindump → approved tickets

+
+ {stage !== 'dump' ? ( +
+
120 ? 'var(--warn)' : 'var(--ink-1)' }}>{clock}
+
budget 2:00
+
+ ) : null} +
+ + {stage === 'dump' ? ( + + +

+ Sentences, fragments, grievances — all welcome. I'll sort it into tickets and only ask what I can't infer. +

+ +
+ ) : null} + + {stage === 'interview' ? ( +
+ +
+ {log.map((e, i) => ( +
+ {e.q} + {e.a} +
+ ))} +

{QUESTIONS[qi].q}

+
+ {QUESTIONS[qi].chips.map((c) => ( + + ))} +
+
+
+ +
+ ) : null} + + {stage === 'review' ? ( +
+ + + + }> +

+ Beta's 80% window moves Mar 3–12 → Mar 5–14. Capacity absorbs the rest. +

+

+ I added the docs ticket you mentioned and wired the dependency. Shall I make it so? +

+
+ +
+ ) : null} + + {stage === 'filed' ? ( + +
+ + Filed + +

+ {tickets.length} issues opened in gitea with est/* and p/* labels — nothing else touched. +

+

+ Elapsed {clock} — under budget. No bot comments, no synthetic issues; your repo remains yours. +

+ +
+
+ ) : null} +
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/screens/deps-graph.tsx b/apps/desktop/src/renderer/src/components/screens/deps-graph.tsx new file mode 100644 index 0000000..ca108ac --- /dev/null +++ b/apps/desktop/src/renderer/src/components/screens/deps-graph.tsx @@ -0,0 +1,130 @@ +import React from 'react' + +import { DEPS, type IssueRef } from '../../data/fixtures.js' +import { Tag, Icon } from '../ui/index.js' + +// Dependency graph drill-in — layered DAG, critical path in spruce +export function DepsGraph({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => void }) { + const g = DEPS + + const PAD = 14, COLW = 206, ROWH = 106, NW = 176, NH = 84 + const X = (c: number) => PAD + c * COLW + const Y = (r: number) => PAD + r * ROWH + const maxCol = g.milestone.col + const maxRow = Math.max(...g.nodes.map((n) => n.row), g.milestone.row) + const W = PAD * 2 + maxCol * COLW + NW + const H = PAD * 2 + maxRow * ROWH + NH + const MSW = 158, MSH = 44 + + const pos: Record = {} + g.nodes.forEach((n) => { pos[n.id] = { x: X(n.col), y: Y(n.row), w: NW, h: NH } }) + pos['ms'] = { x: X(g.milestone.col), y: Y(g.milestone.row) + (NH - MSH) / 2, w: MSW, h: MSH } + + const edgePath = (e: { from: number; to: number | 'ms'; crit?: boolean }) => { + const a = pos[e.from], b = pos[e.to] + const x1 = a.x + a.w, y1 = a.y + a.h / 2 + const x2 = b.x, y2 = b.y + b.h / 2 + const dx = Math.max(28, (x2 - x1) / 2) + return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2 - 5} ${y2}` + } + + const STATES: Record = { + done: { icon: 'circle-check', color: 'var(--ok)', label: 'done' }, + steeping: { icon: 'clock', color: 'var(--warn)', label: 'steeping' }, + review: { icon: 'git-pull-request', color: 'var(--info)', label: 'in review' }, + triage: { icon: 'circle-dot', color: 'var(--ink-3)', label: 'triage' }, + diagnosis: { icon: 'circle-dashed', color: 'var(--ink-3)', label: 'diagnosis' }, + } + + const Node = ({ n }: { n: (typeof DEPS.nodes)[number] }) => { + const st = STATES[n.state] + const crit = g.critical.includes(n.id) + return ( +
onOpenIssue({ id: n.id, title: n.title, labels: n.tags, rationale: n.rationale, days: n.state === 'steeping' ? n.days : undefined })} + style={{ + position: 'absolute', left: pos[n.id].x, top: pos[n.id].y, width: NW, height: NH, + background: n.state === 'done' ? 'var(--paper-2)' : 'var(--surface-card)', + border: `1px solid ${crit ? 'var(--spruce-5)' : 'var(--line-1)'}`, + boxShadow: crit ? 'var(--shadow-1), inset 2px 0 0 var(--accent)' : 'var(--shadow-1)', + borderRadius: 'var(--radius-2)', padding: '9px 11px', cursor: 'pointer', + display: 'flex', flexDirection: 'column', gap: 6, + opacity: n.state === 'done' ? 0.72 : 1, + transition: 'border-color var(--duration-fast) var(--ease-out)', + }} + onMouseEnter={(e: React.MouseEvent) => { e.currentTarget.style.borderColor = crit ? 'var(--accent)' : 'var(--line-2)' }} + onMouseLeave={(e: React.MouseEvent) => { e.currentTarget.style.borderColor = crit ? 'var(--spruce-5)' : 'var(--line-1)' }} + > +
{n.title}
+
+ #{n.id} + + + {st.label}{n.state === 'steeping' && n.days ? ` ${n.days}` : ''} + + {n.tags.filter((t) => t.startsWith('p/')).map((t) => ( + {t} + ))} +
+
+ ) + } + + return ( +
+ {/* legend */} +
+ + critical path + + + blocks + + unattached: {g.unattached.map((i) => `#${i}`).join(' · ')} +
+ + {/* graph canvas */} +
+
+ + + + + + + + + + {g.edges.map((e, i) => ( + + ))} + + {g.nodes.map((n) => )} + {/* milestone terminal */} +
+ +
+ {g.milestone.name} + due {g.milestone.due} +
+
+
+
+ +

+ 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 new file mode 100644 index 0000000..13b5cc4 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/screens/directives-screen.tsx @@ -0,0 +1,104 @@ +import React from 'react' + +import { DIRECTIVES, type DirectivePending, type DirectiveEntry } from '../../data/fixtures.js' +import { Card, Button, Badge, Icon } from '../ui/index.js' + +// Directive log — append-only ledger + the consequence diff (propose-approve) +export function DirectivesScreen() { + const [pending, setPending] = React.useState(DIRECTIVES.pending) + const [entries, setEntries] = React.useState(DIRECTIVES.entries) + + 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

+
+ + {pending ? ( + + + + + }> +
+

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

+
+ {pending.diff.map((r) => ( +
+ + {r.change} + + {r.from} {r.to} + +
+ ))} +
+

+ 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 new file mode 100644 index 0000000..f20941a --- /dev/null +++ b/apps/desktop/src/renderer/src/components/screens/focus-screen.tsx @@ -0,0 +1,99 @@ +import React from 'react' + +import { FOCUS, type FocusIssue, type IssueRef, TODAY } from '../../data/fixtures.js' +import { BurnUpCone } from '../charts/chart.js' +import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js' + +/** + * Morning service — the Now/Next/Later focus cards + the milestone burn-up cone. + * Data is fixture (data.js) until P2's scheduler + Monte Carlo feed it. + */ +export function FocusScreen({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => void }) { + const FocusRow = ({ slot, issue, jade }: { slot: string; issue: FocusIssue; jade?: boolean }) => ( + { + e.preventDefault() + onOpenIssue({ id: issue.id, title: issue.title, labels: issue.labels }) + }} + style={{ color: 'inherit', border: 'none' }} + > + {issue.title} + + } + actions={} + footer={ + jade ? ( + <> + + + + scheduler pick · critical path + + + ) : null + } + > +
+ #{issue.id} + {issue.labels.map((l) => ( + + ))} + {issue.steeping ? ( + + steeping {issue.steeping} + + ) : null} +
+

{issue.rationale}

+
+ ) + + return ( +
+
+
+

Morning service

+

+ {TODAY} · reconcile 3.2s +

+
+ + ahead of forecast + +
+ +
+ + + +
+ + 80% this lands Mar 3–12} + actions={} + > + +

+ The cone has narrowed since Friday. I’m quietly pleased. +

+
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/screens/gantt-view.tsx b/apps/desktop/src/renderer/src/components/screens/gantt-view.tsx new file mode 100644 index 0000000..14b2a68 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/screens/gantt-view.tsx @@ -0,0 +1,105 @@ +import React from 'react' + +import { GANTT, type IssueRef } from '../../data/fixtures.js' +import { Icon } from '../ui/index.js' + +// Gantt drill-in — scheduler-derived bars, critical chain, 80% forecast tails +export function GanttView({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => void }) { + const g = GANTT; + + const LABELW = 232, DAYW = 21, ROWH = 36, HEADH = 30; + const chartW = g.days * DAYW; + const W = LABELW + chartW; + const H = HEADH + g.rows.length * ROWH; + const X = (d: number) => LABELW + d * DAYW; + + const BAR: Record = { + done: { bg: 'var(--paper-3)', border: 'transparent', text: 'var(--ink-3)' }, + steeping: { bg: 'var(--accent)', border: 'transparent', text: 'var(--ink-inverse)' }, + review: { bg: 'var(--info-tint)', border: 'var(--info)', text: 'var(--info)' }, + scheduled: { bg: 'var(--spruce-2)', border: 'var(--spruce-3)', text: 'var(--accent-text)' }, + }; + + return ( +
+ {/* legend */} +
+ {[['steeping', 'in work'], ['review', 'in review'], ['scheduled', 'scheduled'], ['done', 'done']].map(([k, label]) => ( + + {label} + + ))} + + 80% tail + + + today + +
+ + {/* chart */} +
+
+ {/* week gridlines + labels */} + {g.weeks.map((w) => ( + +
+ {w.label} +
+ ))} + {/* milestone 80% band */} +
+ {g.band.label} + {/* due marker */} +
+ + {/* today rule */} +
+ + {/* rows */} + {g.rows.map((r, i) => { + const top = HEADH + i * ROWH; + const st = BAR[r.state]; + return ( + + {/* row hairline */} +
+ {/* label cell (sticky) */} +
onOpenIssue({ id: r.id, title: r.title, labels: [], days: r.state === 'steeping' ? '4d' : undefined })} + style={{ + position: 'absolute', left: 0, width: LABELW, height: ROWH, top: top, zIndex: 2, cursor: 'pointer', + display: 'flex', alignItems: 'center', gap: 8, padding: '0 12px 0 14px', background: 'var(--surface-card)', + borderRight: '1px solid var(--line-1)', + boxShadow: r.crit ? 'inset 2px 0 0 var(--accent)' : 'none', + }} + > + #{r.id} + {r.title} + {r.who} +
+ {/* bar */} +
+ {/* 80% tail */} + {r.p80 ? ( + <> +
+
+ + ) : null} +
+ ); + })} +
+
+ +

+ 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 new file mode 100644 index 0000000..46f0519 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/screens/inbox-screen.tsx @@ -0,0 +1,107 @@ +import React from 'react' + +import { INBOX, type InboxItem, type IssueRef } from '../../data/fixtures.js' +import { Button, Card, Icon, Tabs } from '../ui/index.js' + +// Inbox — Reginald only rings the bell when it matters +export function InboxScreen({ + onOpenIssue, + onOpenDirectives, + readIds, + setReadIds, +}: { + onOpenIssue: (issue: IssueRef) => void + onOpenDirectives: () => void + readIds: number[] + setReadIds: React.Dispatch> +}) { + const all = INBOX + const [tab, setTab] = React.useState('all') + + const isRead = (n: InboxItem) => !n.unread || readIds.includes(n.id) + const unreadCount = all.filter((n) => !isRead(n)).length + + const FILTERS: Record boolean> = { + all: () => true, + mentions: (n) => n.type === 'mention' || n.type === 'assignment', + drift: (n) => n.type === 'drift' || n.type === 'nag' || n.type === 'milestone', + system: (n) => n.type === 'system' || n.type === 'review', + } + const items = all.filter(FILTERS[tab]) + const days = [...new Set(items.map((n) => n.day))] + + const toneColor: Record = { ok: 'var(--ok)', warn: 'var(--warn)', info: 'var(--info)', neutral: 'var(--ink-3)' } + + const open = (n: InboxItem) => { + setReadIds((r) => (r.includes(n.id) ? r : [...r, n.id])) + if (n.issue) onOpenIssue(n.issue) + else if (n.to === 'directives') onOpenDirectives() + } + + return ( +
+
+
+

Inbox

+

+ {unreadCount ? `${unreadCount} unread` : 'all read'} · nothing here rings twice +

+
+ {unreadCount ? ( + + ) : null} +
+ + + + +
+ {days.map((day) => ( +
+
{day}
+ {items.filter((n) => n.day === day).map((n) => { + const read = isRead(n) + return ( +
open(n)} + style={{ + display: 'flex', alignItems: 'flex-start', gap: 12, padding: '11px 20px', + cursor: n.issue || n.to ? 'pointer' : 'default', + opacity: read ? 0.72 : 1, + }} + onMouseEnter={(e: React.MouseEvent) => { e.currentTarget.style.background = 'var(--paper-2)' }} + onMouseLeave={(e: React.MouseEvent) => { e.currentTarget.style.background = 'transparent' }} + > + + + + +
+
+ {n.who ? {n.who} : null}{n.text} +
+
{n.detail}
+
+ {n.time} +
+ ) + })} +
+ ))} +
+
+ +

+ I only ring the bell when it matters. The rest can wait for morning service. +

+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx b/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx new file mode 100644 index 0000000..60d115c --- /dev/null +++ b/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx @@ -0,0 +1,184 @@ +// Issue detail — human intent (gitea) on the left, machine-derived (pm-state) on the right +import React from 'react' + +import { ISSUE_DETAIL, type IssueDetail, type IssueRef } from '../../data/fixtures.js' +import { Badge, Button, Card, Icon, Tag } from '../ui/index.js' + +export function IssueScreen({ + issue, + onBack, + onOpenIssue, +}: { + issue: IssueRef + onBack: () => void + onOpenIssue: (issue: IssueRef) => void +}) { + const det: IssueDetail = ISSUE_DETAIL[issue.id] || { + state: 'triage', + assignee: 'Stephen', + milestone: 'Beta', + body: '', + comments: [], + lifecycle: [ + { stage: 'Diagnosis', event: 'issue opened', when: 'Feb 4 · 10:20', icon: 'circle-dot', done: true }, + { stage: 'Triage', event: 'labeled · milestoned Beta', when: 'Feb 5 · 09:12', icon: 'tag', done: true }, + { stage: 'Work start', event: 'first branch or commit ref', when: 'pending', icon: 'git-commit-horizontal', done: false }, + { stage: 'Deploy', event: 'PR merged', when: 'pending', icon: 'git-merge', done: false }, + { stage: 'Complete', event: 'issue closed', when: 'pending', icon: 'circle-check', done: false }, + ], + forecast: { p80: 'starts wk of Feb 16', note: 'queue position from scheduler' }, + blocks: [], + blockedBy: [], + note: '', + } + const stateBadge = ( + { + steeping: { tone: 'warn', label: `steeping${issue.days ? ' ' + issue.days : ''}` }, + triage: { tone: 'neutral', label: 'triage' }, + review: { tone: 'info', label: 'in review' }, + done: { tone: 'ok', label: 'done' }, + } as Record + )[det.state] || { tone: 'neutral', label: det.state } + + return ( +
+ {/* breadcrumb + header */} +
+ +
+
+

#{issue.id} · stephen/commitea

+

{issue.title}

+
+ {stateBadge.label} + {(issue.labels || []).map((l) => )} + + {det.milestone} + + + {det.assignee} + +
+
+ +
+
+ +
+ {/* left: human intent */} +
+ + {det.body ? ( +

{det.body}

+ ) : ( +

+ No description was written. I have opinions about that, but I'll keep them warm. +

+ )} +
+ + +
+ {det.comments.map((c, i) => ( +
+ {c.who.split(' ').map((w) => w[0]).join('')} +
+
+ {c.who} + {c.when} +
+

{c.text}

+
+
+ ))} +
+ + +
+
+
+ + {det.note ? ( +

{det.note}

+ ) : null} +
+ + {/* right: machine-derived sidecar */} + +
+ {/* lifecycle */} +
+ {det.lifecycle.map((s, i) => ( +
+
+ + + + {i < det.lifecycle.length - 1 ? : null} +
+
+
{s.stage}
+
{s.event}
+
{s.when}
+
+
+ ))} +
+ {/* forecast */} +
+
Forecast
+
80% {det.forecast.p80}
+
{det.forecast.note}
+
+ {/* dependencies */} +
+
Dependencies
+ {det.blocks.length === 0 && det.blockedBy.length === 0 ? ( +
none
+ ) : ( +
+ {det.blocks.length ? ( +
+ blocks + {det.blocks.map((b) => ( + + ))} +
+ ) : null} + {det.blockedBy.length ? ( +
+ blocked by + {det.blockedBy.map((b) => #{b})} +
+ ) : null} +
+ )} +
+ {/* provenance note */} +
+

+ Lives in pm-state. Your repo never sees any of it. +

+
+
+
+
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/screens/milestone-screen.tsx b/apps/desktop/src/renderer/src/components/screens/milestone-screen.tsx new file mode 100644 index 0000000..fd9930b --- /dev/null +++ b/apps/desktop/src/renderer/src/components/screens/milestone-screen.tsx @@ -0,0 +1,102 @@ +import React from 'react' + +import { COLUMNS, type IssueRef } from '../../data/fixtures.js' +import { BurnUpCone } from '../charts/chart.js' +import { Badge, Button, Card, Icon, Tag } from '../ui/index.js' + +// Milestone detail — scope, cone, issues; forecasts stay ranges +export function MilestoneScreen({ onBack, onOpenIssue }: { onBack: () => void; onOpenIssue: (issue: IssueRef) => void }) { + const cols = COLUMNS + const byState = (ids: number[]) => + cols.flatMap((c) => c.issues.map((i) => ({ ...i, col: c.label }))).filter((i) => ids.includes(i.id)) + + const groups = [ + { 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 }, + ] + + const Stat = ({ label, value, tone }: { label: string; value: string; tone?: string }) => ( +
+
{label}
+
{value}
+
+ ) + + return ( +
+
+ +
+
+

+ milestone · due Mar 15 · soft — scope may flex +

+

Beta

+
+ ahead of forecast + 80% Mar 3–12 +
+
+ +
+
+ + {/* stats strip */} + +
+ + + +
+
Drift · 7d
+
−2d · cone narrowed
+
+
+
+ +
+ 80% this lands Mar 3–12} jade> + +

+ Comfortably ahead. Beta needs #87 more than it needs my commentary. +

+
+ + +
+ {groups.map((g) => ( +
+
+ {g.label} + {g.issues.length} +
+ {g.issues.map((i) => ( +
onOpenIssue({ id: i.id, title: i.title, labels: i.labels, days: i.days })} + style={{ + display: 'flex', alignItems: 'center', gap: 8, padding: '7px 20px', cursor: 'pointer', + opacity: g.muted ? 0.6 : 1, + }} + onMouseEnter={(e: React.MouseEvent) => { e.currentTarget.style.background = 'var(--paper-2)'; }} + onMouseLeave={(e: React.MouseEvent) => { e.currentTarget.style.background = 'transparent'; }} + > + #{i.id} + {i.title} + {(i.labels || []).filter((l) => l.startsWith('est/')).map((l) => )} +
+ ))} +
+ ))} +
+
+
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/screens/onboarding-screen.tsx b/apps/desktop/src/renderer/src/components/screens/onboarding-screen.tsx new file mode 100644 index 0000000..6e20ad9 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/screens/onboarding-screen.tsx @@ -0,0 +1,169 @@ +import React from 'react' + +import logoIcon from '../../design/assets/logo-icon.png' +import { Badge, Button, Icon, Input, Radio, 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 + + React.useEffect(() => { + if (conn !== 'testing') return + const t = setTimeout(() => setConn('ok'), 1100) + return () => clearTimeout(t) + }, [conn]) + + React.useEffect(() => { + if (boot < 0 || boot >= 3) return + const t = setTimeout(() => setBoot(boot + 1), 700) + return () => clearTimeout(t) + }, [boot]) + + 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 Frame = ({ children, footer }: { children: React.ReactNode; footer?: React.ReactNode }) => ( +
+ {children} + {footer ?
{footer}
: null} +
+ ) + + return ( +
+ {/* brand */} +
+ + + CommiTea + +
+ + {/* stepper */} +
+ {STEPS.map((s, i) => ( +
+ + {i < step ? '✓' : i + 1} + {s} + + {i < STEPS.length - 1 ? : null} +
+ ))} +
+ +
+ {step === 0 ? ( + 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. +

+

+ Your plans live in your own Gitea as ordinary issues and labels. Delete me and nothing human is lost. +

+ + ) : null} + + {step === 1 ? ( + + + + }> +

Your Gitea

+ + +
+ + {conn === 'ok' ? connected · 3 repos visible : null} +
+ + ) : null} + + {step === 2 ? ( + + + + }> +

Which repo shall I manage?

+
+ {['stephen/commitea', 'stephen/novelpad', 'stephen/infra'].map((r: string) => ( + + ))} +
+

One to start. You can add more later in Settings.

+ + ) : null} + + {step === 3 ? ( + + + + : <> + + + }> +

+ {boot === 3 ? 'All set.' : 'With your approval'} +

+
+ {BOOT_TASKS.map((t: string, i: number) => ( +
+ i ? 'var(--ok)' : boot === i ? 'var(--warn)' : 'var(--ink-3)' }}> + i ? 'circle-check' : boot === i ? 'loader-circle' : 'circle-dashed'} size={15} /> + + i ? 'var(--ink-1)' : 'var(--ink-2)', whiteSpace: 'nowrap' }}>{t} +
+ ))} +
+ {['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d', 'p/1', 'p/2', 'p/3', 'p/4', 'deadline/hard'].map((l: string) => )} +
+
+ {boot === 3 ? ( +

+ The pot is empty. Tell me what you're planning and I'll draw up the tickets. +

+ ) : ( +

+ No bot comments, no body frontmatter, no synthetic issues — ever. Labels are the only footprint. +

+ )} + + ) : null} +
+ + first run · everything reversible +
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/screens/runway-screen.tsx b/apps/desktop/src/renderer/src/components/screens/runway-screen.tsx new file mode 100644 index 0000000..cd93a6c --- /dev/null +++ b/apps/desktop/src/renderer/src/components/screens/runway-screen.tsx @@ -0,0 +1,80 @@ +import React from 'react' + +import { RunwayBar } from '../charts/chart.js' +import { Card, Badge, Tag, Icon, IconButton } from '../ui/index.js' +import { RUNWAY, CAPACITY } from '../../data/fixtures.js' + +// Runway — capacity vs milestone dates; ranges, never points +export function RunwayScreen({ + onOpenCalibration, + onOpenMilestone, +}: { + onOpenCalibration: () => void + onOpenMilestone: () => void +}) { + return ( +
+
+

Runway

+

capacity vs milestone dates · calibrated on 27 closed issues

+
+ + +
+ {RUNWAY.map((m, i) => ( +
) => { e.currentTarget.style.background = 'var(--paper-2)'; }} + onMouseLeave={(e: React.MouseEvent) => { e.currentTarget.style.background = 'transparent'; }}> +
+
+ {m.name} +
+
+ due {m.due}{m.hard ? ' ' : ''} +
+ {m.hard ? : null} +
+ + 80% {m.p80} + {m.note} +
+ ))} +
+
+ +
+ +
+ {CAPACITY.map((p, i) => ( +
+ {p.who.split(' ').map((w: string) => w[0]).join('')} +
+
{p.who}
+
{p.slices}
+
+ {p.hours} +
+ ))} +
+
+ }> +

+ Your estimates run 18% optimistic on est/3d and above. Smaller tickets are honest. +

+

+ I widen the cone accordingly. No judgement — it's the most common shape of hope. +

+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/screens/settings-screen.tsx b/apps/desktop/src/renderer/src/components/screens/settings-screen.tsx new file mode 100644 index 0000000..377983a --- /dev/null +++ b/apps/desktop/src/renderer/src/components/screens/settings-screen.tsx @@ -0,0 +1,137 @@ +import React from 'react' + +import { Badge, Button, Card, Icon, IconButton, Input, Radio, Select, Switch, Tag } from '../ui/index.js' + +// Settings — gitea connection, sync, model roles, labels, rituals, appearance +export function SettingsScreen({ dark, setDark }: { dark: boolean; setDark: (v: boolean) => void }) { + const [webhooks, setWebhooks] = React.useState(true) + const [reconcile, setReconcile] = React.useState(true) + const [poll, setPoll] = React.useState(true) + const [nag, setNag] = React.useState(true) + + const Row = ({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) => ( +
{children}
+ ) + const Note = ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ) + + return ( +
+
+

Settings

+

config lives in pm-state · versioned, portable

+
+ + +
+ + +
+ Managed repos + + + stephen/commitea + syncing + + + + + stephen/pm-state + sidecar + + The sidecar holds machine-derived state only. Delete it and resync — no truth is lost. + +
+
+
+ + +
+ + setWebhooks(e.target.checked)} /> + endpoint :48731 · healthy + + setReconcile(e.target.checked)} /> + + setPoll(e.target.checked)} /> +
+ + + +
+ + hot memory ≤ 2k tokens · math is never delegated to either + +

+ The small one writes my standup; the large one argues with your estimates. Neither is allowed near the arithmetic. +

+
+
+ + +
+ + {['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d'].map((l) => )} + + + {['p/1', 'p/2', 'p/3', 'p/4'].map((l) => )} + + + Fixed sets, human-meaningful, visible in gitea. Not configurable — that is rather the point. +
+
+ + +
+ + Morning standup +
+ +
+
+
+
+ + +
+ setDark(false)} /> + setDark(true)} /> +
+
+ + + +
+
Forget this gitea
+ Removes the connection and the local cache. Gitea itself is untouched. +
+ +
+
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/screens/standup-screen.tsx b/apps/desktop/src/renderer/src/components/screens/standup-screen.tsx new file mode 100644 index 0000000..51e2e0a --- /dev/null +++ b/apps/desktop/src/renderer/src/components/screens/standup-screen.tsx @@ -0,0 +1,212 @@ +import React from 'react' + +import { type IssueRef, STANDUP } from '../../data/fixtures.js' +import { Badge, Button, Icon } from '../ui/index.js' + +const standupCSS = ` +@keyframes ct-standup-settle { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: none; } +} +.ct-standup-section { opacity: 1; } +@media (prefers-reduced-motion: no-preference) { + .ct-standup-section { animation: ct-standup-settle 320ms var(--ease-out) both; } +} +` +;(function inject() { + if (typeof document !== 'undefined' && !document.getElementById('ct-standup-css')) { + const s = document.createElement('style') + s.id = 'ct-standup-css' + s.textContent = standupCSS + document.head.appendChild(s) + } +})() + +const toneColor: Record = { + ok: 'var(--ok)', + warn: 'var(--warn)', + danger: 'var(--danger)', +} + +/** + * Morning standup — a typeset letter from Reginald: overnight drift, today's + * plan, stale-blocker nag. Sections settle in once, reduced-motion-safe. Data + * is fixture (data.js) until the scheduler + lifecycle inference feed it. + */ +export function StandupScreen({ + onBegin, + onOpenIssue, +}: { + onBegin: () => void + onOpenIssue: (issue: IssueRef) => void +}) { + const s = STANDUP + const NAG_REF: IssueRef = { + id: s.nag.id, + title: 'Fix lifecycle inference on merge events', + labels: ['est/2d', 'p/1'], + days: s.nag.days, + } + + const Section = ({ overline, order, children }: { overline: string; order: number; children: React.ReactNode }) => ( +
+

+ {overline} +

+ {children} +
+ ) + + return ( +
+
+
+

{s.date} · prepared 07:00

+

Morning standup

+
+ +
+
+ {s.drift.map((d) => ( +
+ + {d.text} + + {d.delta} + +
+ ))} +
+
+ +
+
+ {s.plan.map((p) => ( +
+ + {p.who + .split(' ') + .map((w) => w[0]) + .join('')} + +
+
+ + {p.who} + + {p.pick} + {p.title} +
+

{p.why}

+
+
+ ))} +
+
+ +
+
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', + }} + > + + + +
+
+ #{s.nag.id} + + steeping {s.nag.days} + + + blocks {s.nag.blocks.join(', ')} + +
+

{s.nag.text}

+
+
+
+ +
+

+ The kettle’s on. — R. +

+ + +
+
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx new file mode 100644 index 0000000..3f56c01 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx @@ -0,0 +1,304 @@ +import React, { useEffect, useState } from 'react' + +import logoIcon from '../../design/assets/logo-icon.png' +import type { IssueRef } from '../../data/fixtures.js' +import { PrimitivesGallery } from '../gallery.js' +import { BoardScreen } from '../screens/board-screen.js' +import { CalibrationScreen } from '../screens/calibration-screen.js' +import { CaptureScreen } from '../screens/capture-screen.js' +import { DirectivesScreen } from '../screens/directives-screen.js' +import { FocusScreen } from '../screens/focus-screen.js' +import { InboxScreen } from '../screens/inbox-screen.js' +import { IssueScreen } from '../screens/issue-screen.js' +import { MilestoneScreen } from '../screens/milestone-screen.js' +import { OnboardingScreen } from '../screens/onboarding-screen.js' +import { RunwayScreen } from '../screens/runway-screen.js' +import { SettingsScreen } from '../screens/settings-screen.js' +import { StandupScreen } from '../screens/standup-screen.js' +import { Icon, Switch } from '../ui/index.js' +import { ChatPanel } from './chat-panel.js' +import { PlaceholderScreen } from './placeholder-screen.js' +import { OfflineBanner, StatesScreen } from './states.js' + +type View = + | 'standup' + | 'focus' + | 'inbox' + | 'capture' + | 'board' + | 'runway' + | 'directives' + | 'settings' + | 'states' + | 'firstrun' + | 'primitives' + | 'issue' + | 'calibration' + | 'milestone' + +interface NavEntry { + id: View + label: string + icon: string + count?: number | null +} + +// which phase builds each not-yet-real view (shown on its placeholder) +const PHASE: Partial> = { + standup: 'P3-3', + focus: 'P3-3', + inbox: 'P3-6', + capture: 'P3-7', + board: 'P3-4', + runway: 'P3-5', + directives: 'P3-8', + settings: 'P3-8', + firstrun: 'P3-8', + issue: 'P3-6', + calibration: 'P3-5', + milestone: 'P3-5', +} + +const TITLE: Partial> = { + standup: 'Standup', + focus: 'Morning service', + inbox: 'Inbox', + capture: 'Capture', + board: 'The pot', + runway: 'Runway', + directives: 'Directives', + settings: 'Settings', + firstrun: 'First run', + issue: 'Issue', + calibration: 'Calibration', + milestone: 'Milestone', +} + +const INBOX_UNREAD = 3 + +export function AppShell() { + const [view, setView] = useState('focus') + const [prevView, setPrevView] = useState('focus') + const [dark, setDark] = useState(false) + const [offline, setOffline] = useState(false) + const [issue, setIssue] = useState(null) + const [readIds, setReadIds] = useState([]) + + useEffect(() => { + document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') + }, [dark]) + + const openIssue = (ref: IssueRef) => { + if (view !== 'issue') setPrevView(view) + setIssue(ref) + setView('issue') + } + + const NAV: NavEntry[] = [ + { id: 'standup', label: 'Standup', icon: 'sun' }, + { id: 'focus', label: 'Morning service', icon: 'coffee' }, + { id: 'inbox', label: 'Inbox', icon: 'bell', count: INBOX_UNREAD || null }, + { id: 'capture', label: 'Capture', icon: 'plus' }, + { id: 'board', label: 'The pot', icon: 'square-kanban' }, + { id: 'runway', label: 'Runway', icon: 'chart-line' }, + { id: 'directives', label: 'Directives', icon: 'flag' }, + ] + + const NavItem = ({ item }: { item: NavEntry }) => { + const active = + view === item.id || + (view === 'issue' && prevView === item.id) || + ((view === 'calibration' || view === 'milestone') && item.id === 'runway') + return ( + + ) + } + + const renderScreen = () => { + switch (view) { + case 'focus': + return + case 'standup': + return setView('focus')} onOpenIssue={openIssue} /> + case 'board': + return + case 'runway': + return ( + setView('calibration')} + onOpenMilestone={() => setView('milestone')} + /> + ) + case 'calibration': + return setView('runway')} /> + case 'milestone': + return setView('runway')} onOpenIssue={openIssue} /> + case 'inbox': + return ( + setView('directives')} + readIds={readIds} + setReadIds={setReadIds} + /> + ) + case 'capture': + return setView('focus')} /> + case 'directives': + return + case 'settings': + return + case 'issue': + return issue ? ( + setView(prevView)} onOpenIssue={openIssue} /> + ) : null + case 'states': + return setView('capture')} /> + case 'primitives': + return + default: + return + } + } + + // First run is full-window — no rail, no chat panel + if (view === 'firstrun') { + return setView(dest)} /> + } + + return ( +
+ {/* left rail */} + + + {/* main */} +
+
+ {offline ? : null} + {renderScreen()} +
+
+ + setView('directives')} offline={offline} /> +
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx b/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx new file mode 100644 index 0000000..fde4e09 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx @@ -0,0 +1,167 @@ +import React, { useEffect, useRef, useState } from 'react' + +import { CANNED_REPLY, CHAT, type ChatMessage } from '../../data/fixtures.js' +import { Icon, IconButton } from '../ui/index.js' + +/** + * Reginald's panel — chat is the write-path (decisions.md D1). This is the P3-2 + * fixture shell: it echoes a canned reply so the layout + interactions are real, + * but no model is wired. P4 replaces `send` with the model router + tools. + */ +export interface ChatPanelProps { + onOpenDirectives?: () => void + offline?: boolean +} + +export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) { + const [msgs, setMsgs] = useState(CHAT) + const [text, setText] = useState('') + const [thinking, setThinking] = useState(false) + const scrollRef = useRef(null) + + useEffect(() => { + const el = scrollRef.current + if (el) el.scrollTop = el.scrollHeight + }, [msgs, thinking]) + + const send = () => { + const t = text.trim() + if (!t) return + setMsgs((m) => [...m, { from: 'user', text: t }]) + setText('') + setThinking(true) + setTimeout(() => { + setThinking(false) + setMsgs((m) => [...m, { from: 'agent', text: CANNED_REPLY }]) + }, 900) + } + + return ( +