diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 0000000..87f7eb2 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,42 @@ +# CommiTea (desktop) + +An AI project manager for Gitea, as an Electron desktop app. The token stays in +the main process (OS keychain via `safeStorage`); the renderer never sees it. + +## Develop + +```bash +yarn dev # electron-vite dev, HMR renderer + main (logs → desktop.log) +yarn typecheck +yarn e2e # builds, then Playwright against the built main (fixtures/demo) +``` + +For a live pass against a real Gitea, put a token in `.env.local` +(`GITEA_TOKEN=…`) and run `GITEA_LIVE=1 yarn e2e live-onboarding`. + +## Package (shareable macOS .dmg) + +```bash +yarn pack # unpacked .app for the host arch (fast sanity check) → dist/ +yarn dist # arm64 + x64 .dmg → dist/CommiTea-[-arm64].dmg +``` + +Config is `electron-builder.yml`. Builds are **unsigned** (no Apple certs, by +decision) — electron-builder ad-hoc signs so the app can run, but it is not +notarized. Bump `version` in `package.json` for a new release; keep +`electronVersion` in `electron-builder.yml` in sync with the `electron` +devDependency. + +### Installing a shared build (teammates) + +macOS Gatekeeper blocks unsigned apps on first launch. To open: + +1. Open the `.dmg` and drag **CommiTea** to **Applications**. +2. In Applications, **right-click CommiTea → Open**, then confirm **Open** in the + dialog. (Double-clicking the first time just shows "cannot be opened".) +3. It opens to the connection screen — enter your Gitea base URL, `owner/repo`, + and a personal access token (repo scope). Each teammate uses their own token, + so activity is attributed correctly. A model URL is optional; chat is disabled + until one is configured, everything else works without it. + +Grab the `-arm64` dmg on Apple Silicon, the plain one on Intel. diff --git a/apps/desktop/build/icon.png b/apps/desktop/build/icon.png new file mode 100644 index 0000000..3fb0699 Binary files /dev/null and b/apps/desktop/build/icon.png differ diff --git a/apps/desktop/e2e/live-backlog.spec.ts b/apps/desktop/e2e/live-backlog.spec.ts index 4362ea2..b610aac 100644 --- a/apps/desktop/e2e/live-backlog.spec.ts +++ b/apps/desktop/e2e/live-backlog.spec.ts @@ -11,6 +11,7 @@ const MAIN = join(here, '..', 'out', 'main', 'index.js') test.describe('live backlog', () => { test('The pot + Focus render real gitea data', async () => { test.skip(!process.env.GITEA_LIVE, 'GITEA_LIVE not set — opt-in live test') + test.setTimeout(90_000) const app = await electron.launch({ args: [MAIN], env: { ...process.env } }) const win = await app.firstWindow() await win.waitForLoadState('domcontentloaded') @@ -42,16 +43,17 @@ test.describe('live backlog', () => { win.getByText(/cold-start priors · \d+\/20 closed issues estimated|calibrated on \d+ closed/), ).toBeVisible() // Real per-milestone forecasts — these milestone names come from gitea, not the - // fixture (which lists Beta / Pilot-ready / v1.0). - await expect(win.getByText(/P2 — Scheduler/)).toBeVisible() + // fixture (Beta / Pilot-ready / v1.0). The runway shows milestones with open + // scope; P5 is active (P2 is fully shipped, so it's correctly omitted). + await expect(win.getByText(/P5 — Dogfood/)).toBeVisible() // Real capacity config from pm-state (christian/stephen), not the fixture (Stephen/Ana K.) await expect(win.getByText('christian', { exact: true })).toBeVisible() await expect(win.getByText(/pd\/day/).first()).toBeVisible() await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-runway.png'), fullPage: true, animations: 'disabled' }) - // Milestone drill-in — clicking a real milestone opens its real detail - await win.getByText(/P2 — Scheduler/).click() - await expect(win.getByRole('heading', { name: 'P2 — Scheduler + Monte Carlo' })).toBeVisible() + // Milestone drill-in — clicking a real (active) milestone opens its real detail + await win.getByText(/P5 — Dogfood/).click() + await expect(win.getByRole('heading', { name: 'P5 — Dogfood + polish' })).toBeVisible() await expect(win.getByText(/\d+ issues · est \d+d/)).toBeVisible() await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-milestone.png'), fullPage: true, animations: 'disabled' }) await rail.getByRole('button', { name: 'Runway' }).click() @@ -68,10 +70,15 @@ test.describe('live backlog', () => { await win.getByRole('main').getByRole('link').first().click() await expect(win.getByText(/· stephen\/commitea/)).toBeVisible() await win.getByRole('button', { name: 'Adjust' }).click() - await expect(win.getByText('Adjust estimate & priority')).toBeVisible() + await expect(win.getByText('Adjust issue')).toBeVisible() await win.getByRole('combobox').first().selectOption('est/8d') - await expect(win.getByText('Proposed label change')).toBeVisible() + await expect(win.getByText('Proposed change')).toBeVisible() await expect(win.getByText(/est\/8d/).last()).toBeVisible() + // the unified tool also drives assignee + milestone — the pickers render and diff + await expect(win.getByText('Assignee', { exact: true })).toBeVisible() + await expect(win.getByText('Milestone', { exact: true })).toBeVisible() + await win.getByRole('combobox').nth(2).selectOption('') // Assignee → Unassigned + await expect(win.getByText('unassign', { exact: true })).toBeVisible() await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-apply-change.png'), fullPage: true, animations: 'disabled' }) await win.getByRole('button', { name: 'Cancel' }).click() // no mutation diff --git a/apps/desktop/e2e/live-onboarding.spec.ts b/apps/desktop/e2e/live-onboarding.spec.ts index 410c1c2..98d9973 100644 --- a/apps/desktop/e2e/live-onboarding.spec.ts +++ b/apps/desktop/e2e/live-onboarding.spec.ts @@ -38,6 +38,11 @@ test.describe('live onboarding', () => { const win = await app.firstWindow() await win.waitForLoadState('domcontentloaded') + // Any uncaught render error in a real-data view builder must fail the test — + // this is the "it actually works on real data" guarantee. + const pageErrors: string[] = [] + win.on('pageerror', (e) => pageErrors.push(e.message)) + // the connection gate, not the app await expect(win.getByText(/Connect your Gitea/)).toBeVisible({ timeout: 15000 }) await win.getByPlaceholder('your-org').fill('christian') @@ -45,11 +50,35 @@ test.describe('live onboarding', () => { await win.getByPlaceholder(/gitea PAT/).fill(token!) await win.getByRole('button', { name: 'Connect' }).click() - // it validated + saved + reconciled into the real board — real issue titles prove it const rail = win.getByRole('navigation', { name: 'Primary' }) + const realTitle = /Model router|SQLite cache bootstrap|Purity\/rebuild/ + + // Board (real reconciled columns) — real issue titles prove the sync landed. await rail.getByRole('button', { name: 'The pot' }).click() - await expect(win.getByText(/Model router|SQLite cache bootstrap|Purity\/rebuild/).first()).toBeVisible({ timeout: 30000 }) + await expect(win.getByText(realTitle).first()).toBeVisible({ timeout: 30000 }) + + // Gantt tab — scheduler-derived bars over the real open backlog. + await win.getByRole('tab', { name: 'Gantt' }).click() + await expect(win.getByText(realTitle).first()).toBeVisible() + + // Dependencies tab — real dep graph. Just needs to render without throwing. + await win.getByRole('tab', { name: 'Dependencies' }).click() + await win.waitForTimeout(300) + + // Issue drill-in — the machine-derived sidecar (lifecycle/forecast/deps) on a real issue. + await win.getByRole('tab', { name: 'Board' }).click() + await win.getByText(realTitle).first().click() + await expect(win.getByText('Machine-derived')).toBeVisible() + await win.getByRole('button', { name: 'Back' }).click() + + // Standup + Inbox — scheduler plan / nag / inbox feed over real data. + await rail.getByRole('button', { name: 'Standup' }).click() + await expect(win.getByRole('heading', { name: 'Morning standup' })).toBeVisible() + await rail.getByRole('button', { name: 'Inbox' }).click() + await expect(win.getByRole('heading', { name: 'Inbox' })).toBeVisible() + await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-onboarding.png'), fullPage: true, animations: 'disabled' }) + expect(pageErrors, `uncaught render errors: ${pageErrors.join(' · ')}`).toEqual([]) await app.close() }) diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml new file mode 100644 index 0000000..596aa67 --- /dev/null +++ b/apps/desktop/electron-builder.yml @@ -0,0 +1,51 @@ +appId: io.stephenmann.commitea +productName: CommiTea +copyright: © CommiTea + +# Pinned because electron is hoisted by the yarn workspace and declared with a +# caret range, which electron-builder can't resolve on its own. Keep in sync with +# the electron devDependency. +electronVersion: 34.5.8 + +directories: + buildResources: build + output: dist + +# The renderer, preload, and main are already bundled into out/ by electron-vite +# (@commitea/core, react, react-dom are all inlined). So the packaged app needs +# nothing from node_modules except the Electron runtime that electron-builder +# supplies — ship only the build output. +files: + - out/** + - '!**/*.map' +npmRebuild: false +electronLanguages: + - en + +mac: + target: + - target: dmg + arch: + - arm64 + - x64 + category: public.app-category.developer-tools + icon: build/icon.png + # Unsigned, per the distribution decision (macOS, no certs). Teammates + # right-click → Open the first time to get past Gatekeeper. + identity: null + # Keep hardened-runtime off — it only matters for signed/notarized builds + # and can block an unsigned app from launching. + hardenedRuntime: false + gatekeeperAssess: false + +dmg: + title: CommiTea ${version} + # A plain drag-to-Applications layout. + contents: + - x: 130 + y: 220 + type: file + - x: 410 + y: 220 + type: link + path: /Applications diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f2e04af..7ea06d6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,5 +1,8 @@ { "name": "@commitea/desktop", + "version": "0.1.0", + "description": "CommiTea — an AI project manager for Gitea", + "author": "CommiTea", "private": true, "type": "module", "main": "./out/main/index.js", @@ -10,7 +13,9 @@ "typecheck": "tsc --noEmit", "e2e": "electron-vite build && playwright test", "e2e:only": "playwright test", - "e2e:report": "playwright show-report e2e/.artifacts/report" + "e2e:report": "playwright show-report e2e/.artifacts/report", + "pack": "electron-vite build && electron-builder --dir", + "dist": "electron-vite build && electron-builder --mac" }, "dependencies": { "@commitea/core": "workspace:*", @@ -25,9 +30,11 @@ "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "electron": "^34.0.0", + "electron-builder": "^25", "electron-vite": "^3.1.0", "postcss": "^8.5.1", "tailwindcss": "^3.4.17", + "tsx": "^4", "typescript": "^5.7.3", "vite": "^6.1.0" } diff --git a/apps/desktop/scripts/dogfood-report.ts b/apps/desktop/scripts/dogfood-report.ts new file mode 100644 index 0000000..466b0b2 --- /dev/null +++ b/apps/desktop/scripts/dogfood-report.ts @@ -0,0 +1,142 @@ +/** + * Dogfood (#31): run CommiTea's own deterministic engine against the live + * christian/commitea backlog and print the project report each screen derives — + * board, critical path, schedule, Monte Carlo forecast, per-milestone runway. + * + * This is the core brain (schedule + forecast + capacity), the exact code the + * desktop app runs. Read-only. Run: yarn tsx scripts/dogfood-report.ts + */ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + createGiteaClient, + forecast, + inferLifecycle, + parseCapacityConfig, + capacityPerWorkday, + scheduleWithCapacity, + type GiteaIssue, + type DependencyEdge, + type Worker, +} from '@commitea/core' + +const here = dirname(fileURLToPath(import.meta.url)) + +function token(): string { + let dir = here + for (let i = 0; i < 6; i++) { + try { + const m = /^GITEA_TOKEN\s*=\s*(.+?)\s*$/m.exec(readFileSync(join(dir, '.env.local'), 'utf8')) + if (m) return m[1].trim() + } catch { + /* keep walking up */ + } + dir = dirname(dir) + } + throw new Error('no GITEA_TOKEN in any .env.local up the tree') +} + +/** working-day offset → calendar date (skip Sat/Sun), then a short label. */ +function addWorkingDays(base: Date, n: number): Date { + const d = new Date(base) + let left = Math.ceil(n) + while (left > 0) { + d.setDate(d.getDate() + 1) + const day = d.getDay() + if (day !== 0 && day !== 6) left-- + } + return d +} +const fmt = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + +async function main() { + const client = createGiteaClient( + { baseUrl: 'https://gitea.stephenmann.io', owner: 'christian', repo: 'commitea', token: token() }, + fetch, + ) + + const all = await client.listIssues({ state: 'all' }) + const open = all.filter((i) => i.state === 'open') + const closed = all.filter((i) => i.state === 'closed') + + // Real dependency edges (open issues only — the scheduler drops out-of-scope endpoints anyway). + const edges: DependencyEdge[] = [] + for (const i of open) { + for (const dep of await client.getIssueDependencies(i.number)) edges.push({ issue: i.number, dependsOn: dep }) + } + + // Capacity lanes from the pm-state repo (a separate repo, exactly like the app's + // pmStateClient — NOT the managed repo). + const pmClient = createGiteaClient( + { baseUrl: 'https://gitea.stephenmann.io', owner: 'christian', repo: 'commitea-pm-state', token: token() }, + fetch, + ) + let workers: Worker[] = [] + try { + const f = await pmClient.getFile('capacity/members.json') + if (f) { + const members = parseCapacityConfig(JSON.parse(Buffer.from(f.contentBase64, 'base64').toString('utf8'))) + workers = members.map((m) => ({ person: m.person, speed: capacityPerWorkday(m) })) + } + } catch { + /* no capacity → single serial worker */ + } + + const toSchedulable = (issues: GiteaIssue[]) => + issues.map((i) => ({ + number: i.number, + title: i.title, + labels: i.labels, + estimateDays: i.facts.estimateDays, + priority: i.facts.priority, + assignee: i.assignee, + })) + + const plan = scheduleWithCapacity(toSchedulable(open), edges, workers) + const today = new Date() + const f = forecast(toSchedulable(open), edges, { workers }) + + const bar = '─'.repeat(64) + console.log(`\n${bar}\n CommiTea, on itself — ${fmt(today)} ${today.getFullYear()}\n${bar}`) + console.log(` Board: ${open.length} open · ${closed.length} done`) + console.log(` Team: ${workers.length ? workers.map((w) => `${w.person} (${w.speed.toFixed(2)}/day)`).join(', ') : 'single serial worker'}`) + const unassigned = open.filter((i) => !i.assignee).length + if (unassigned) console.log(` ⚠ ${unassigned}/${open.length} open issues have no assignee`) + + console.log(`\n Schedule (dependency + priority order, ${workers.length || 1} lane${workers.length === 1 ? '' : 's'}):`) + for (const it of plan.items) { + const crit = it.critical ? ' ★crit' : '' + const lane = it.worker ? ` [${it.worker}]` : '' + const blocks = it.blocks.length ? ` blocks ${it.blocks.map((b) => '#' + b).join(',')}` : '' + console.log( + ` #${String(it.number).padEnd(3)} ${fmt(addWorkingDays(today, it.startDay))}→${fmt(addWorkingDays(today, it.endDay))}${crit.padEnd(7)}${lane} ${it.title.slice(0, 38)}${blocks}`, + ) + } + if (plan.cycle) console.log(` ⚠ dependency cycle: ${plan.cycle.join(' → ')}`) + + const p90 = f.curve.length ? f.curve[f.curve.length - 1].p90Day : f.p95Day + console.log(`\n Forecast (Monte Carlo, ${f.trials} trials, ${f.coldStart ? 'cold-start priors' : 'fitted'}):`) + console.log(` whole backlog lands p50 ${fmt(addWorkingDays(today, f.p50Day))} · p80 ${fmt(addWorkingDays(today, f.p80Day))} · p90 ${fmt(addWorkingDays(today, p90))}`) + + // Per-milestone runway. + const ms = await client.listMilestones() + console.log(`\n Runway (per open milestone):`) + for (const m of ms.filter((x) => x.state === 'open')) { + const scope = open.filter((i) => i.milestone?.id === m.id) + if (!scope.length) continue + const mf = forecast(toSchedulable(scope), edges, { workers }) + const mp90 = mf.curve.length ? mf.curve[mf.curve.length - 1].p90Day : mf.p95Day + const due = m.dueOn ? fmt(new Date(m.dueOn)) : 'no due date' + console.log( + ` ${m.title.padEnd(28)} ${scope.length} open · p50 ${fmt(addWorkingDays(today, mf.p50Day))}–p90 ${fmt(addWorkingDays(today, mp90))} (due: ${due})`, + ) + } + console.log(`\n${bar}\n`) +} + +main().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/apps/desktop/src/main/gitea.ts b/apps/desktop/src/main/gitea.ts index 8334d54..1bafee0 100644 --- a/apps/desktop/src/main/gitea.ts +++ b/apps/desktop/src/main/gitea.ts @@ -19,6 +19,7 @@ import { type GiteaConfig, type GiteaLabel, type IssueChange, + isLabelChange, type LifecycleEvent, makeDirectiveEntry, parseCapacityConfig, @@ -263,19 +264,32 @@ export function registerGiteaIpc(): void { return client.getIssue(index) }) - // The write path (apply_changes). Additive label swaps, applied only after the - // renderer's propose-approve. Returns the plan + the freshly-read issue. + // The write path (apply_changes), applied only after the renderer's propose- + // approve. Label swaps (est/p) return the plan; field writes (assign, milestone) + // return the freshly-read issue directly. Either way the snapshot is invalidated + // so the board + forecast reflect the change. ipcMain.handle('gitea:applyChange', async (_event, change: IssueChange) => { const client = getGiteaClient() if (!client) return { ok: false as const, reason: 'unconfigured' as const } - const current = await client.getIssue(change.issue) - const plan = planIssueChange(current.labels, change) - if (plan.noop) return { ok: true as const, plan, issue: current } - const ids = await resolveLabelIds(client, plan.labels) - await client.setIssueLabels(change.issue, ids) - const issue = await client.getIssue(change.issue) - invalidateSnapshot() // the board + forecast must reflect the label change - return { ok: true as const, plan, issue } + + if (isLabelChange(change)) { + const current = await client.getIssue(change.issue) + const plan = planIssueChange(current.labels, change) + if (plan.noop) return { ok: true as const, plan, issue: current } + const ids = await resolveLabelIds(client, plan.labels) + await client.setIssueLabels(change.issue, ids) + const issue = await client.getIssue(change.issue) + invalidateSnapshot() + return { ok: true as const, plan, issue } + } + + // Field writes — the client returns the updated issue. + const issue = + change.kind === 'assign' + ? await client.setIssueAssignees(change.issue, change.assignee ? [change.assignee] : []) + : await client.setIssueMilestone(change.issue, change.milestone) + invalidateSnapshot() + return { ok: true as const, issue } }) // capture_work filing: open each approved issue with its est/* + p/* labels. @@ -319,6 +333,17 @@ export function registerGiteaIpc(): void { } }) + // Assignable people (repo collaborators) for the Adjust dialog's assignee picker. + ipcMain.handle('gitea:collaborators', async () => { + const client = getGiteaClient() + if (!client) return [] + try { + return await client.listCollaborators() + } catch { + return [] + } + }) + // ---- config (team onboarding) ---- ipcMain.handle('config:get', () => publicConfig()) diff --git a/apps/desktop/src/main/model.ts b/apps/desktop/src/main/model.ts index a4c7711..7883448 100644 --- a/apps/desktop/src/main/model.ts +++ b/apps/desktop/src/main/model.ts @@ -13,7 +13,6 @@ import { type ChangeProposal, type ChatMessage, createChatClient, - describeChange, type ModelRouter, type ProjectView, proposalsFor, @@ -130,10 +129,19 @@ export function registerModelIpc(): void { const a = (args ?? {}) as ProposeChangeArgs const issue = await client.getIssue(a.issue).catch(() => null) if (!issue) return { error: `issue #${a.issue} not found` } - const built = proposalsFor(a, issue.labels, issue.title) + // Milestone titles + current assignee/milestone let a proposal label the + // milestone and skip a no-op assign/move. + const milestones = (a.milestone !== undefined ? await client.listMilestones().catch(() => []) : []).map( + (m) => ({ id: m.id, title: m.title }), + ) + const built = proposalsFor(a, issue.labels, issue.title, { + currentAssignee: issue.assignee, + currentMilestoneId: issue.milestone?.id ?? null, + milestones, + }) proposals.push(...built) return built.length - ? { proposed: built.map((p) => ({ issue: a.issue, diff: describeChange(p.plan) })) } + ? { proposed: built.map((p) => ({ issue: a.issue, diff: p.summary })) } : { proposed: [], note: 'no change — already at that value' } } if (name === 'record_directive') { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index cd8c2c9..b780fee 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -15,6 +15,8 @@ const api = { applyChange: (change: unknown) => ipcRenderer.invoke('gitea:applyChange', change), /** File a set of captured issues with their est/* + p/* labels. */ createIssues: (issues: unknown) => ipcRenderer.invoke('gitea:createIssues', issues), + /** Assignable people (repo collaborators) for the assignee picker. */ + collaborators: () => ipcRenderer.invoke('gitea:collaborators'), }, pmstate: { /** Read the directive ledger from the pm-state repo. */ diff --git a/apps/desktop/src/renderer/src/components/screens/board-screen.tsx b/apps/desktop/src/renderer/src/components/screens/board-screen.tsx index a51fcb9..da3fe31 100644 --- a/apps/desktop/src/renderer/src/components/screens/board-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/board-screen.tsx @@ -1,6 +1,6 @@ import React from 'react' -import { COLUMNS, type BoardColumn, type BoardIssue, type IssueRef } from '../../data/fixtures.js' +import { COLUMNS, type BoardColumn, type BoardIssue, type DepsData, type GanttData, type IssueRef } from '../../data/fixtures.js' import { Card, Tag, Badge, Tabs, IconButton, Input, Icon } from '../ui/index.js' import { EmptyState } from '../shell/states.js' import { GanttView } from './gantt-view.js' @@ -12,10 +12,14 @@ import { DepsGraph } from './deps-graph.js' export function BoardScreen({ onOpenIssue, columns = COLUMNS, + gantt, + deps, loading = false, }: { onOpenIssue: (issue: IssueRef) => void columns?: BoardColumn[] + gantt?: GanttData + deps?: DepsData loading?: boolean }) { const [tab, setTab] = React.useState('board'); @@ -90,9 +94,9 @@ export function BoardScreen({ ) ) : tab === 'deps' ? ( - + ) : ( - + )} ); 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 ca108ac..e7d7ce9 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,11 @@ import React from 'react' -import { DEPS, type IssueRef } from '../../data/fixtures.js' +import { DEPS, type DepsData, 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 +export function DepsGraph({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef) => void; data?: DepsData }) { + const g = data ?? DEPS const PAD = 14, COLW = 206, ROWH = 106, NW = 176, NH = 84 const X = (c: number) => PAD + c * COLW 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 14b2a68..52908cc 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,11 @@ import React from 'react' -import { GANTT, type IssueRef } from '../../data/fixtures.js' +import { GANTT, type GanttData, 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; +export function GanttView({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef) => void; data?: GanttData }) { + const g = data ?? GANTT; const LABELW = 232, DAYW = 21, ROWH = 36, HEADH = 30; const chartW = g.days * DAYW; 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 46f0519..7079862 100644 --- a/apps/desktop/src/renderer/src/components/screens/inbox-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/inbox-screen.tsx @@ -9,13 +9,15 @@ export function InboxScreen({ onOpenDirectives, readIds, setReadIds, + data, }: { onOpenIssue: (issue: IssueRef) => void onOpenDirectives: () => void readIds: number[] setReadIds: React.Dispatch> + data?: InboxItem[] }) { - const all = INBOX + const all = data ?? INBOX const [tab, setTab] = React.useState('all') const isRead = (n: InboxItem) => !n.unread || readIds.includes(n.id) 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 fb2f76d..b1e2685 100644 --- a/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx @@ -2,13 +2,12 @@ import React, { useState } from 'react' import { - describeChange, type EstimateLabel, ESTIMATE_LABELS, type IssueChange, - planIssueChange, type PriorityLabel, PRIORITY_LABELS, + summarizeChange, } from '@commitea/core' import { ISSUE_DETAIL, type IssueDetail, type IssueRef } from '../../data/fixtures.js' @@ -18,18 +17,28 @@ const NONE = '—' export function IssueScreen({ issue, + detail, onBack, onOpenIssue, canWrite = false, onApplyChange, + collaborators = [], + milestones = [], + currentAssignee = null, + currentMilestoneId = null, }: { issue: IssueRef + detail?: IssueDetail onBack: () => void onOpenIssue: (issue: IssueRef) => void canWrite?: boolean onApplyChange?: (change: IssueChange) => Promise<{ ok: boolean }> + collaborators?: { login: string; name: string }[] + milestones?: { id: number; title: string }[] + currentAssignee?: string | null + currentMilestoneId?: number | null }) { - const det: IssueDetail = ISSUE_DETAIL[issue.id] || { + const det: IssueDetail = detail ?? ISSUE_DETAIL[issue.id] ?? { state: 'triage', assignee: 'Stephen', milestone: 'Beta', @@ -60,25 +69,40 @@ export function IssueScreen({ const labels = issue.labels ?? [] const curEstimate = labels.find((l) => l.startsWith('est/')) ?? '' const curPriority = labels.find((l) => l.startsWith('p/')) ?? '' + const curAssignee = currentAssignee ?? '' + const curMilestone = currentMilestoneId != null ? String(currentMilestoneId) : '' const [adjustOpen, setAdjustOpen] = useState(false) const [estimate, setEstimate] = useState(curEstimate) const [priority, setPriority] = useState(curPriority) + const [assignee, setAssignee] = useState(curAssignee) + const [milestone, setMilestone] = useState(curMilestone) const [applying, setApplying] = useState(false) const openAdjust = () => { setEstimate(curEstimate) setPriority(curPriority) + setAssignee(curAssignee) + setMilestone(curMilestone) setAdjustOpen(true) } - // the concrete changes this dialog would apply, one per axis that differs + // the concrete changes this dialog would apply, one per field that differs const pendingChanges: IssueChange[] = [] if (estimate !== curEstimate) pendingChanges.push({ kind: 'reestimate', issue: issue.id, estimate: (estimate || null) as EstimateLabel | null }) if (priority !== curPriority) pendingChanges.push({ kind: 'reprioritize', issue: issue.id, priority: (priority || null) as PriorityLabel | null }) + if (assignee !== curAssignee) + pendingChanges.push({ kind: 'assign', issue: issue.id, assignee: assignee || null }) + if (milestone !== curMilestone) + pendingChanges.push({ + kind: 'remilestone', + issue: issue.id, + milestone: milestone ? Number(milestone) : null, + milestoneTitle: milestones.find((m) => String(m.id) === milestone)?.title ?? null, + }) - const diffs = pendingChanges.map((c) => describeChange(planIssueChange(labels, c))) + const diffs = pendingChanges.map((c) => summarizeChange(c, labels)) const apply = async () => { if (!onApplyChange || pendingChanges.length === 0) return @@ -93,6 +117,14 @@ export function IssueScreen({ const estOptions = [{ value: '', label: NONE }, ...ESTIMATE_LABELS.map((l) => ({ value: l, label: l }))] const prioOptions = [{ value: '', label: NONE }, ...PRIORITY_LABELS.map((l) => ({ value: l, label: l }))] + const assigneeOptions = [ + { value: '', label: 'Unassigned' }, + ...collaborators.map((c) => ({ value: c.login, label: c.name })), + ] + const milestoneOptions = [ + { value: '', label: NONE }, + ...milestones.map((m) => ({ value: String(m.id), label: m.title })), + ] return (
@@ -133,7 +165,7 @@ export function IssueScreen({ setAdjustOpen(false)} - title="Adjust estimate & priority" + title="Adjust issue" footer={ <>
+ {(collaborators.length > 0 || milestones.length > 0) && ( +
+ {collaborators.length > 0 ? ( + setMilestone(e.target.value)} + /> + ) : null} +
+ )}
{pendingChanges.length === 0 ? (

- No change yet — pick a different estimate or priority. + No change yet — pick a different estimate, priority, assignee, or milestone.

) : ( <>

- Proposed label change + Proposed change

{diffs.map((d) => (
@@ -175,7 +227,7 @@ export function IssueScreen({
))}

- Writes the label to gitea and re-runs the plan. Nothing else changes. + Writes to gitea and re-runs the plan. Nothing else changes.

)} @@ -267,7 +319,7 @@ export function IssueScreen({ blocks {det.blocks.map((b) => ( diff --git a/apps/desktop/src/renderer/src/components/screens/standup-screen.tsx b/apps/desktop/src/renderer/src/components/screens/standup-screen.tsx index 51e2e0a..ff43818 100644 --- a/apps/desktop/src/renderer/src/components/screens/standup-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/standup-screen.tsx @@ -1,6 +1,6 @@ import React from 'react' -import { type IssueRef, STANDUP } from '../../data/fixtures.js' +import { type IssueRef, STANDUP, type StandupData } from '../../data/fixtures.js' import { Badge, Button, Icon } from '../ui/index.js' const standupCSS = ` @@ -36,15 +36,15 @@ const toneColor: Record = { export function StandupScreen({ onBegin, onOpenIssue, + data, }: { onBegin: () => void onOpenIssue: (issue: IssueRef) => void + data?: StandupData }) { - const s = STANDUP + const s = data ?? 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, } diff --git a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx index ff934c9..3763759 100644 --- a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx +++ b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx @@ -17,6 +17,12 @@ import { } from '../../lib/backlog.js' import { useBacklog } from '../../lib/use-backlog.js' import { useCapacity } from '../../lib/use-capacity.js' +import { depsGraphView } from '../../lib/views/deps-graph.js' +import { ganttView } from '../../lib/views/gantt-view.js' +import { inboxView } from '../../lib/views/inbox-view.js' +import { issueDetailView } from '../../lib/views/issue-detail.js' +import type { ProjectData } from '../../lib/views/project-data.js' +import { standupView } from '../../lib/views/standup-view.js' import { PrimitivesGallery } from '../gallery.js' import { BoardScreen } from '../screens/board-screen.js' import { CalibrationScreen } from '../screens/calibration-screen.js' @@ -100,7 +106,9 @@ export function AppShell() { const [readIds, setReadIds] = useState([]) const [milestoneId, setMilestoneId] = useState(null) const [gate, setGate] = useState<'checking' | 'connect' | 'ready'>('checking') + const [demo, setDemo] = useState(false) const [pubConfig, setPubConfig] = useState(null) + const [collaborators, setCollaborators] = useState<{ login: string; name: string }[]>([]) const [backlog, refetchBacklog] = useBacklog() const capacityMembers = useCapacity() const workers = capacityWorkers(capacityMembers) @@ -132,6 +140,41 @@ export function AppShell() { ) ?? undefined) : undefined + // The uniform input every real-data view builder consumes. Assembled once from + // the reconciled backlog + capacity; undefined until the first reconcile lands. + const projectData: ProjectData | undefined = + backlog.status === 'ready' + ? { + issues: backlog.issues, + milestones: backlog.milestones, + deps: backlog.deps, + timelines: backlog.timelines, + calibration: calibration?.model, + workers, + today: new Date(), + } + : undefined + const issueDetail = + projectData && issue ? (issueDetailView(issue.id, projectData) ?? undefined) : undefined + const standup = projectData ? standupView(projectData) : undefined + const inbox = projectData ? inboxView(projectData) : undefined + const gantt = projectData ? ganttView(projectData) : undefined + const depsGraph = projectData ? depsGraphView(projectData) : undefined + // The rail's unread badge tracks the real inbox once reconciled (still-unread + // minus what's been opened); demo shows the fixture count. + const inboxUnread = inbox + ? inbox.filter((n) => n.unread && !readIds.includes(n.id)).length + : INBOX_UNREAD + + // The real connected host (from the saved config), for the rail's status line. + const hostLabel = (() => { + try { + return pubConfig?.baseUrl ? new URL(pubConfig.baseUrl).host : 'gitea' + } catch { + return pubConfig?.baseUrl ?? 'gitea' + } + })() + useEffect(() => { document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') }, [dark]) @@ -141,14 +184,34 @@ export function AppShell() { useEffect(() => { window.commitea.gitea .status() - .then((s) => setGate(s.demo || s.configured ? 'ready' : 'connect')) + .then((s) => { + setDemo(s.demo) + setGate(s.demo || s.configured ? 'ready' : 'connect') + }) .catch(() => setGate('connect')) window.commitea.config.get().then(setPubConfig).catch(() => {}) + // Assignable people for the Adjust dialog's assignee picker (empty when demo/unconfigured). + window.commitea.gitea.collaborators().then(setCollaborators).catch(() => {}) }, []) + // The open issue's current assignee + milestone (from the reconciled backlog), + // so the Adjust dialog can preselect and diff them. Milestones list feeds its picker. + const currentIssue = + backlog.status === 'ready' && issue ? backlog.issues.find((i) => i.number === issue.id) : undefined + const milestoneOptions = + backlog.status === 'ready' ? backlog.milestones.map((m) => ({ id: m.id, title: m.title })) : [] + const openIssue = (ref: IssueRef) => { if (view !== 'issue') setPrevView(view) - setIssue(ref) + // Callers into the sidecar (e.g. a "blocks #N" chip) may know only the id. + // Backfill the real title + labels from the reconciled backlog so the header + // isn't blank; fall back to whatever the caller passed (demo fixtures). + let full = ref + if (backlog.status === 'ready' && (!ref.title || !ref.labels)) { + const found = backlog.issues.find((i) => i.number === ref.id) + if (found) full = { ...ref, id: found.number, title: found.title, labels: found.labels } + } + setIssue(full) setView('issue') } @@ -166,7 +229,7 @@ export function AppShell() { 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: 'inbox', label: 'Inbox', icon: 'bell', count: inboxUnread || null }, { id: 'capture', label: 'Capture', icon: 'plus' }, { id: 'board', label: 'The pot', icon: 'square-kanban' }, { id: 'runway', label: 'Runway', icon: 'chart-line' }, @@ -224,12 +287,14 @@ export function AppShell() { case 'focus': return case 'standup': - return setView('focus')} onOpenIssue={openIssue} /> + return setView('focus')} onOpenIssue={openIssue} data={standup} /> case 'board': return ( ) @@ -265,6 +330,7 @@ export function AppShell() { onOpenDirectives={() => setView('directives')} readIds={readIds} setReadIds={setReadIds} + data={inbox} /> ) case 'capture': @@ -290,10 +356,15 @@ export function AppShell() { return issue ? ( setView(prevView)} onOpenIssue={openIssue} canWrite={backlog.status === 'ready'} onApplyChange={applyChange} + collaborators={collaborators} + milestones={milestoneOptions} + currentAssignee={currentIssue?.assignee ?? null} + currentMilestoneId={currentIssue?.milestone?.id ?? null} /> ) : null case 'states': @@ -364,9 +435,15 @@ export function AppShell() {
- - - + {/* Dev-only surfaces (fixture galleries / onboarding preview) — shown in dev + and in demo/e2e mode; hidden in a real configured, packaged app. */} + {import.meta.env.DEV || demo ? ( + <> + + + + + ) : null}
Evening service} diff --git a/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx b/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx index d396d13..f614251 100644 --- a/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx +++ b/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from 'react' -import { describeChange, type IssueChange } from '@commitea/core' +import type { IssueChange } from '@commitea/core' import { useChat } from '../../lib/use-chat.js' import { Button, Icon, IconButton } from '../ui/index.js' @@ -127,7 +127,7 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane
Proposed · #{p.change.issue}
-
{describeChange(p.plan)}
+
{p.summary}