Compare commits
20 Commits
feat/sqlit
...
1000d053b3
| Author | SHA1 | Date | |
|---|---|---|---|
| 1000d053b3 | |||
|
|
f48f75257a | ||
|
|
a04714bfa6 | ||
|
|
27edbbd6af | ||
|
|
cf30cc1d0f | ||
| e317c1099d | |||
|
|
73643efcbc | ||
| b07f07c14d | |||
|
|
ea8f1184e6 | ||
| c0dd9e70ef | |||
| 842661c9a9 | |||
| 717dc7348f | |||
| d1a4c4410c | |||
| 345b561591 | |||
| 2a6322e99a | |||
| 481cacd99a | |||
|
|
f08c4935dc | ||
|
|
e5ce3fe87a | ||
|
|
b65ee4c8ad | ||
|
|
19e83ff8ba |
@@ -1,5 +1,13 @@
|
|||||||
import { expect, test } from './fixtures.js'
|
import { expect, test } from './fixtures.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke suite — runs in demo mode (COMMITEA_E2E=1), where the main process serves
|
||||||
|
* the fixed demo snapshot (apps/desktop/src/main/demo-snapshot.ts) with NO live
|
||||||
|
* gitea. The app renders it through the exact same view builders as real data, so
|
||||||
|
* these assertions exercise the real render path against deterministic input.
|
||||||
|
* Full onboarding + agent flows live in the GITEA_LIVE-gated live-*.spec files.
|
||||||
|
*/
|
||||||
|
|
||||||
test('boots into the shell with the Focus screen', async ({ app, window }) => {
|
test('boots into the shell with the Focus screen', async ({ app, window }) => {
|
||||||
await app.expectLoaded()
|
await app.expectLoaded()
|
||||||
await expect(window.getByText('Reginald', { exact: true })).toBeVisible()
|
await expect(window.getByText('Reginald', { exact: true })).toBeVisible()
|
||||||
@@ -7,121 +15,83 @@ test('boots into the shell with the Focus screen', async ({ app, window }) => {
|
|||||||
await app.screenshot('shell-light')
|
await app.screenshot('shell-light')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Focus shows Now/Next/Later cards and the burn-up cone', async ({ window }) => {
|
test('Focus shows the Now/Next/Later cards from the scheduler', async ({ window }) => {
|
||||||
for (const slot of ['Now', 'Next', 'Later']) {
|
for (const slot of ['Now', 'Next', 'Later']) {
|
||||||
await expect(window.getByText(slot, { exact: true })).toBeVisible()
|
await expect(window.getByText(slot, { exact: true })).toBeVisible()
|
||||||
}
|
}
|
||||||
await expect(window.getByText('Fix lifecycle inference on merge events')).toBeVisible()
|
// the cards link to real demo issues (which specific one is scheduler-dependent)
|
||||||
await expect(window.getByText(/scope · 42 issues/)).toBeVisible()
|
await expect(window.getByRole('main').getByRole('link').first()).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Standup renders the drift / plan / nag letter', async ({ app, window }) => {
|
test('Standup renders the drift / plan letter', async ({ app, window }) => {
|
||||||
await app.nav('Standup').click()
|
await app.nav('Standup').click()
|
||||||
await expect(window.getByRole('heading', { name: 'Morning standup' })).toBeVisible()
|
await expect(window.getByRole('heading', { name: 'Morning standup' })).toBeVisible()
|
||||||
await expect(window.getByText('Overnight drift')).toBeVisible()
|
await expect(window.getByText("Today's plan")).toBeVisible()
|
||||||
await expect(window.getByText('Stephen', { exact: true })).toBeVisible()
|
|
||||||
await app.screenshot('standup')
|
await app.screenshot('standup')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Board: columns, search filter, and Gantt/Deps tabs', async ({ app, window }) => {
|
test('Board: lifecycle columns, a demo issue, and the search filter', async ({ app, window }) => {
|
||||||
await app.nav('The pot').click()
|
await app.nav('The pot').click()
|
||||||
await expect(window.getByRole('heading', { name: 'The pot' })).toBeVisible()
|
await expect(window.getByRole('heading', { name: 'The pot' })).toBeVisible()
|
||||||
await expect(window.getByText('Diagnosis', { exact: true })).toBeVisible()
|
// lifecycle columns populated by the demo snapshot's event stream
|
||||||
await expect(window.getByText('Steeping', { exact: true })).toBeVisible()
|
await expect(window.getByText('Steeping', { exact: true })).toBeVisible()
|
||||||
|
await expect(window.getByText('Done', { exact: true })).toBeVisible()
|
||||||
|
await expect(window.getByText('Fix lifecycle inference on merge events')).toBeVisible()
|
||||||
await app.screenshot('board')
|
await app.screenshot('board')
|
||||||
|
|
||||||
await window.getByPlaceholder(/Search the pot/).fill('lifecycle')
|
await window.getByPlaceholder(/Search the pot/).fill('monte carlo')
|
||||||
await expect(window.getByText('Fix lifecycle inference on merge events')).toBeVisible()
|
await expect(window.getByText('Monte Carlo forecast bands on the runway')).toBeVisible()
|
||||||
await expect(window.getByText('Dark theme: cone fill too faint')).toBeHidden()
|
await expect(window.getByText('Fix lifecycle inference on merge events')).toBeHidden()
|
||||||
await window.getByPlaceholder(/Search the pot/).fill('')
|
await window.getByPlaceholder(/Search the pot/).fill('')
|
||||||
|
|
||||||
|
// the Gantt + Dependencies tabs render from the same snapshot
|
||||||
await window.getByRole('tab', { name: 'Gantt' }).click()
|
await window.getByRole('tab', { name: 'Gantt' }).click()
|
||||||
await expect(window.getByText(/80% · Mar 3–12/)).toBeVisible()
|
|
||||||
await app.screenshot('gantt')
|
await app.screenshot('gantt')
|
||||||
|
|
||||||
await window.getByRole('tab', { name: 'Dependencies' }).click()
|
await window.getByRole('tab', { name: 'Dependencies' }).click()
|
||||||
await expect(window.getByText('critical path', { exact: true })).toBeVisible()
|
|
||||||
await app.screenshot('deps')
|
await app.screenshot('deps')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Runway: milestones, capacity, and drill-ins', async ({ app, window }) => {
|
test('Runway shows the demo milestones', async ({ app, window }) => {
|
||||||
await app.nav('Runway').click()
|
await app.nav('Runway').click()
|
||||||
await expect(window.getByRole('heading', { name: 'Runway' })).toBeVisible()
|
await expect(window.getByRole('heading', { name: 'Runway' })).toBeVisible()
|
||||||
await expect(window.getByText('Pilot-ready')).toBeVisible()
|
await expect(window.getByText('Beta', { exact: true })).toBeVisible()
|
||||||
await expect(window.getByText('deadline/hard')).toBeVisible()
|
|
||||||
await app.screenshot('runway')
|
await app.screenshot('runway')
|
||||||
|
|
||||||
// milestone drill-in
|
|
||||||
await window.getByText('Beta', { exact: true }).click()
|
|
||||||
await expect(window.getByRole('heading', { name: 'Beta' })).toBeVisible()
|
|
||||||
await expect(window.getByText('42 issues · est 61d')).toBeVisible()
|
|
||||||
await app.screenshot('milestone')
|
|
||||||
await window.getByRole('main').getByRole('button', { name: 'Runway' }).click()
|
|
||||||
|
|
||||||
// calibration drill-in
|
|
||||||
await window.getByRole('button', { name: 'Full report' }).click()
|
|
||||||
await expect(window.getByRole('heading', { name: 'Calibration' })).toBeVisible()
|
|
||||||
await expect(window.getByText('The shape of hope')).toBeVisible()
|
|
||||||
await app.screenshot('calibration')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Inbox: notifications and mark-all-read', async ({ app, window }) => {
|
test('Inbox renders', async ({ app, window }) => {
|
||||||
await app.nav('Inbox').click()
|
await app.nav('Inbox').click()
|
||||||
await expect(window.getByRole('heading', { name: 'Inbox' })).toBeVisible()
|
await expect(window.getByRole('heading', { name: 'Inbox' })).toBeVisible()
|
||||||
await expect(window.getByText(/window moved/)).toBeVisible()
|
|
||||||
await app.screenshot('inbox')
|
await app.screenshot('inbox')
|
||||||
await window.getByRole('button', { name: 'Mark all read' }).click()
|
|
||||||
await expect(window.getByText('all read · nothing here rings twice')).toBeVisible()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Capture: braindump advances to the interview', async ({ app, window }) => {
|
test('Capture: braindump prompt', async ({ app, window }) => {
|
||||||
await app.nav('Capture').click()
|
await app.nav('Capture').click()
|
||||||
await expect(window.getByText("Tell me what you're planning")).toBeVisible()
|
await expect(window.getByText("Tell me what you're planning")).toBeVisible()
|
||||||
await app.screenshot('capture')
|
await app.screenshot('capture')
|
||||||
await window.getByRole('button', { name: 'Brew tickets' }).click()
|
|
||||||
await expect(window.getByText(/Interview · 1 of 3/)).toBeVisible()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Directives: consequence diff + append-only ledger', async ({ app, window }) => {
|
test('Directives: the live ledger from pm-state', async ({ app, window }) => {
|
||||||
await app.nav('Directives').click()
|
await app.nav('Directives').click()
|
||||||
await expect(window.getByRole('heading', { name: 'Directives' })).toBeVisible()
|
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 expect(window.getByText(/append-only · JSONL in pm-state/)).toBeVisible()
|
||||||
|
// a demo directive quote from the snapshot
|
||||||
|
await expect(window.getByText(/Beta ships the 12th, hard/)).toBeVisible()
|
||||||
await app.screenshot('directives')
|
await app.screenshot('directives')
|
||||||
// resolving the pending directive moves it into the ledger
|
|
||||||
await window.getByRole('button', { name: 'Make it so' }).click()
|
|
||||||
await expect(window.getByText('Consequence diff')).toBeHidden()
|
|
||||||
await expect(window.getByText(/Nothing awaits your word/)).toBeVisible()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Settings: connection, schema, and appearance sync with theme', async ({ app, window }) => {
|
test('Settings: connection + appearance, theme sync', async ({ app, window }) => {
|
||||||
await app.nav('Settings').click()
|
await app.nav('Settings').click()
|
||||||
await expect(window.getByRole('heading', { name: 'Settings' })).toBeVisible()
|
await expect(window.getByRole('heading', { name: 'Settings' })).toBeVisible()
|
||||||
await expect(window.getByRole('heading', { name: 'Connection' })).toBeVisible()
|
await expect(window.getByRole('heading', { name: 'Connection' })).toBeVisible()
|
||||||
await app.screenshot('settings')
|
await app.screenshot('settings')
|
||||||
// the Evening (dark) radio drives the shared theme
|
|
||||||
await window.getByText('Evening (dark)').click()
|
await window.getByText('Evening (dark)').click()
|
||||||
await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark')
|
await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Onboarding: full-window first-run flow with test gate', async ({ app, window }) => {
|
test('issue drill-in from a Focus card and back', async ({ app, window }) => {
|
||||||
await app.nav('First run').click()
|
// Focus cards render the issue title as a link; open the first and come back.
|
||||||
await expect(window.getByRole('heading', { name: 'Good morning.' })).toBeVisible()
|
await window.getByRole('main').getByRole('link').first().click()
|
||||||
// full-window: no rail wordmark link, brand only
|
await expect(window.getByRole('button', { name: 'Back' })).toBeVisible()
|
||||||
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 app.screenshot('issue')
|
||||||
await window.getByRole('button', { name: 'Back' }).click()
|
await window.getByRole('button', { name: 'Back' }).click()
|
||||||
await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible()
|
await expect(window.getByRole('heading', { name: 'Morning service' })).toBeVisible()
|
||||||
@@ -133,12 +103,6 @@ test('Evening service toggle flips the document theme', async ({ app, window })
|
|||||||
await app.screenshot('shell-dark')
|
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 }) => {
|
test('exposes the commitea preload API', async ({ app }) => {
|
||||||
const api = await app.preloadApi()
|
const api = await app.preloadApi()
|
||||||
expect(api).toBeDefined()
|
expect(api).toBeDefined()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
"e2e": "electron-vite build && playwright test",
|
"e2e": "electron-vite build && playwright test",
|
||||||
"e2e:only": "playwright test",
|
"e2e:only": "playwright test",
|
||||||
"e2e:report": "playwright show-report e2e/.artifacts/report",
|
"e2e:report": "playwright show-report e2e/.artifacts/report",
|
||||||
|
|||||||
152
apps/desktop/src/main/demo-snapshot.ts
Normal file
152
apps/desktop/src/main/demo-snapshot.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Deterministic demo snapshot — the ONLY test fixture, served by the main process
|
||||||
|
* when COMMITEA_E2E=1 so the e2e suite (and a no-token first look) runs WITHOUT a
|
||||||
|
* live Gitea. It's gitea-shaped on purpose: the reconcile handlers return it and
|
||||||
|
* the app renders it through the exact same view builders (schedule, forecast,
|
||||||
|
* lifecycle, standup, runway) as real data. There are no screen-level mocks — this
|
||||||
|
* is the single source of demo truth. Not shipped behavior; test infrastructure.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
type CapacityMember,
|
||||||
|
type DependencyEdge,
|
||||||
|
type DirectiveRecord,
|
||||||
|
extractLabelFacts,
|
||||||
|
type GiteaIssue,
|
||||||
|
type GiteaMilestone,
|
||||||
|
type LifecycleEvent,
|
||||||
|
} from '@commitea/core'
|
||||||
|
|
||||||
|
const HOST = 'https://gitea.demo.local'
|
||||||
|
const OWNER = 'teatime'
|
||||||
|
const REPO = 'commitea'
|
||||||
|
|
||||||
|
/** Fixed clock so lifecycle math + forecasts are stable across runs. */
|
||||||
|
export const DEMO_TODAY = '2026-03-02T09:00:00Z'
|
||||||
|
|
||||||
|
interface Seed {
|
||||||
|
number: number
|
||||||
|
title: string
|
||||||
|
labels: string[]
|
||||||
|
state?: 'open' | 'closed'
|
||||||
|
milestone?: number
|
||||||
|
assignee?: string
|
||||||
|
events?: LifecycleEvent[]
|
||||||
|
closedAt?: string
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MILESTONES_RAW = [
|
||||||
|
{ id: 1, title: 'Beta', description: 'Pilot-ready', dueOn: '2026-03-12T00:00:00Z', state: 'open' as const },
|
||||||
|
{ id: 2, title: 'v1.0', description: 'General availability', dueOn: '2026-05-01T00:00:00Z', state: 'open' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
const MREF = (id: number) => {
|
||||||
|
const m = MILESTONES_RAW.find((x) => x.id === id)!
|
||||||
|
return { id: m.id, title: m.title, dueOn: m.dueOn }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A small, believable backlog for a team building CommiTea. Timelines place issues
|
||||||
|
// across the five lifecycle columns: bare → diagnosis, labeled → triage, a commit
|
||||||
|
// ref → steeping, a PR ref → review, closed → done.
|
||||||
|
const SEEDS: Seed[] = [
|
||||||
|
{ number: 42, title: 'Fix lifecycle inference on merge events', labels: ['est/3d', 'p/1', 'deadline/hard'], milestone: 1, assignee: 'stephen',
|
||||||
|
events: [{ type: 'triage', at: '2026-02-24T10:00:00Z' }, { type: 'commit', at: '2026-02-27T14:00:00Z' }] },
|
||||||
|
{ number: 41, title: 'Monte Carlo forecast bands on the runway', labels: ['est/5d', 'p/1'], milestone: 1, assignee: 'ana',
|
||||||
|
events: [{ type: 'triage', at: '2026-02-23T09:00:00Z' }, { type: 'commit', at: '2026-02-26T09:00:00Z' }, { type: 'pull', at: '2026-03-01T16:00:00Z' }] },
|
||||||
|
{ number: 40, title: 'Encrypted token store in the main process', labels: ['est/2d', 'p/2'], milestone: 1, assignee: 'stephen',
|
||||||
|
events: [{ type: 'triage', at: '2026-02-22T11:00:00Z' }] },
|
||||||
|
{ number: 39, title: 'Deterministic scheduler with capacity lanes', labels: ['est/8d', 'p/2'], milestone: 1, assignee: 'ana',
|
||||||
|
events: [{ type: 'triage', at: '2026-02-20T09:00:00Z' }, { type: 'commit', at: '2026-02-28T09:00:00Z' }] },
|
||||||
|
{ number: 38, title: 'Standup ritual: overnight drift + today’s plan', labels: ['est/3d', 'p/2'], milestone: 1, assignee: 'stephen',
|
||||||
|
events: [{ type: 'triage', at: '2026-02-25T09:00:00Z' }] },
|
||||||
|
{ number: 37, title: 'Offline write-queue with replay on reconnect', labels: ['est/5d', 'p/3'], milestone: 2, assignee: 'ana',
|
||||||
|
events: [{ type: 'triage', at: '2026-02-19T09:00:00Z' }] },
|
||||||
|
{ number: 36, title: 'Calibration from closed-issue actuals', labels: ['est/3d', 'p/3'], milestone: 2, assignee: 'stephen',
|
||||||
|
events: [{ type: 'triage', at: '2026-02-18T09:00:00Z' }] },
|
||||||
|
{ number: 35, title: 'Reginald: agent loop over query_project', labels: ['est/8d', 'p/2'], milestone: 2, assignee: 'ana',
|
||||||
|
events: [{ type: 'triage', at: '2026-02-17T09:00:00Z' }] },
|
||||||
|
{ number: 34, title: 'Board columns from lifecycle events', labels: [], // bare → diagnosis
|
||||||
|
events: [] },
|
||||||
|
{ number: 33, title: 'Deep-link URIs across orgs', labels: [], // bare → diagnosis
|
||||||
|
events: [] },
|
||||||
|
// shipped work — closed → done, feeds calibration actuals
|
||||||
|
{ number: 30, title: 'SQLite cache bootstrap', labels: ['est/3d', 'p/2'], milestone: 1, assignee: 'ana', state: 'closed',
|
||||||
|
createdAt: '2026-02-10T09:00:00Z', closedAt: '2026-02-14T17:00:00Z',
|
||||||
|
events: [{ type: 'triage', at: '2026-02-10T09:00:00Z' }, { type: 'commit', at: '2026-02-11T09:00:00Z' }, { type: 'pull', at: '2026-02-13T09:00:00Z' }, { type: 'close', at: '2026-02-14T17:00:00Z' }] },
|
||||||
|
{ number: 29, title: 'Connection + target-repo config', labels: ['est/2d', 'p/2'], milestone: 1, assignee: 'stephen', state: 'closed',
|
||||||
|
createdAt: '2026-02-09T09:00:00Z', closedAt: '2026-02-12T15:00:00Z',
|
||||||
|
events: [{ type: 'triage', at: '2026-02-09T09:00:00Z' }, { type: 'commit', at: '2026-02-10T09:00:00Z' }, { type: 'close', at: '2026-02-12T15:00:00Z' }] },
|
||||||
|
{ number: 24, title: 'Unified apply_changes mutation tool', labels: ['est/5d', 'p/1'], milestone: 1, assignee: 'ana', state: 'closed',
|
||||||
|
createdAt: '2026-02-05T09:00:00Z', closedAt: '2026-02-11T12:00:00Z',
|
||||||
|
events: [{ type: 'triage', at: '2026-02-05T09:00:00Z' }, { type: 'commit', at: '2026-02-06T09:00:00Z' }, { type: 'pull', at: '2026-02-10T09:00:00Z' }, { type: 'close', at: '2026-02-11T12:00:00Z' }] },
|
||||||
|
]
|
||||||
|
|
||||||
|
function toIssue(s: Seed): GiteaIssue {
|
||||||
|
const state = s.state ?? 'open'
|
||||||
|
return {
|
||||||
|
number: s.number,
|
||||||
|
title: s.title,
|
||||||
|
body: '',
|
||||||
|
state,
|
||||||
|
labels: s.labels,
|
||||||
|
facts: extractLabelFacts(s.labels),
|
||||||
|
milestone: s.milestone ? MREF(s.milestone) : null,
|
||||||
|
assignee: s.assignee ?? null,
|
||||||
|
assignees: s.assignee ? [s.assignee] : [],
|
||||||
|
createdAt: s.createdAt ?? '2026-02-20T09:00:00Z',
|
||||||
|
updatedAt: DEMO_TODAY,
|
||||||
|
closedAt: s.closedAt ?? null,
|
||||||
|
url: `${HOST}/${OWNER}/${REPO}/issues/${s.number}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEMO_ISSUES: GiteaIssue[] = SEEDS.map(toIssue)
|
||||||
|
|
||||||
|
export const DEMO_MILESTONES: GiteaMilestone[] = MILESTONES_RAW.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
title: m.title,
|
||||||
|
description: m.description,
|
||||||
|
dueOn: m.dueOn,
|
||||||
|
state: m.state,
|
||||||
|
openIssues: DEMO_ISSUES.filter((i) => i.milestone?.id === m.id && i.state === 'open').length,
|
||||||
|
closedIssues: DEMO_ISSUES.filter((i) => i.milestone?.id === m.id && i.state === 'closed').length,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// #41 (forecast bands) depends on #39 (scheduler); #42 (lifecycle) depends on #34 (columns).
|
||||||
|
export const DEMO_DEPS: DependencyEdge[] = [
|
||||||
|
{ issue: 41, dependsOn: 39 },
|
||||||
|
{ issue: 42, dependsOn: 34 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEMO_TIMELINES: Record<number, LifecycleEvent[]> = Object.fromEntries(
|
||||||
|
SEEDS.map((s) => [s.number, s.events ?? []]),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const DEMO_CAPACITY: CapacityMember[] = [
|
||||||
|
{ person: 'stephen', focusFactor: 0.6, allocation: 1 },
|
||||||
|
{ person: 'ana', focusFactor: 1, allocation: 0.8 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEMO_COLLABORATORS: { login: string; name: string }[] = [
|
||||||
|
{ login: 'stephen', name: 'Stephen Mann' },
|
||||||
|
{ login: 'ana', name: 'Ana Koval' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEMO_DIRECTIVES: DirectiveRecord[] = [
|
||||||
|
{ seq: 1, id: 'demo-1', ts: '2026-02-26T10:00:00Z', status: 'accepted', kind: 'reprioritize',
|
||||||
|
quote: 'Bump the lifecycle fix — it’s blocking the board.', target: { issue: 42 }, params: { priority: 1 },
|
||||||
|
rationale: 'Board columns are wrong until #42 lands.' },
|
||||||
|
{ seq: 2, id: 'demo-2', ts: '2026-02-24T15:00:00Z', status: 'accepted', kind: 'capacity',
|
||||||
|
quote: 'Stephen’s at 60% this sprint — half on support.', params: { person: 'stephen', focusFactor: 0.6 },
|
||||||
|
rationale: 'Support rotation.' },
|
||||||
|
{ seq: 3, id: 'demo-3', ts: '2026-02-22T09:00:00Z', status: 'amended', kind: 'set-deadline',
|
||||||
|
quote: 'Beta ships the 12th, hard.', target: { milestone: 1 }, params: { due: '2026-03-12' } },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** The full gitea-shaped snapshot the reconcile handlers return in demo mode. */
|
||||||
|
export const DEMO_SNAPSHOT = {
|
||||||
|
issues: DEMO_ISSUES,
|
||||||
|
milestones: DEMO_MILESTONES,
|
||||||
|
deps: DEMO_DEPS,
|
||||||
|
timelines: DEMO_TIMELINES,
|
||||||
|
}
|
||||||
@@ -12,7 +12,10 @@ import { dirname, join } from 'node:path'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
appendDirective,
|
appendDirective,
|
||||||
|
applySchemaLabels,
|
||||||
createGiteaClient,
|
createGiteaClient,
|
||||||
|
discoverRepos,
|
||||||
|
ensurePmStateRepo,
|
||||||
GiteaApiError,
|
GiteaApiError,
|
||||||
type DirectiveEntry,
|
type DirectiveEntry,
|
||||||
type GiteaClient,
|
type GiteaClient,
|
||||||
@@ -30,6 +33,18 @@ import {
|
|||||||
} from '@commitea/core'
|
} from '@commitea/core'
|
||||||
import { ipcMain } from 'electron'
|
import { ipcMain } from 'electron'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEMO_CAPACITY,
|
||||||
|
DEMO_COLLABORATORS,
|
||||||
|
DEMO_DIRECTIVES,
|
||||||
|
DEMO_SNAPSHOT,
|
||||||
|
DEMO_TODAY,
|
||||||
|
} from './demo-snapshot.js'
|
||||||
|
|
||||||
|
/** Demo mode: the e2e harness (and a no-token first look) render the fixed demo
|
||||||
|
* snapshot through the real view builders — no live gitea, no screen-level mocks. */
|
||||||
|
const isDemo = (): boolean => process.env.COMMITEA_E2E === '1'
|
||||||
|
|
||||||
import { type AppConfig, clearConfig, loadConfig, publicConfig, saveConfig } from './config-store.js'
|
import { type AppConfig, clearConfig, loadConfig, publicConfig, saveConfig } from './config-store.js'
|
||||||
import { loadSnapshot, saveSnapshot } from './snapshot-store.js'
|
import { loadSnapshot, saveSnapshot } from './snapshot-store.js'
|
||||||
|
|
||||||
@@ -238,12 +253,14 @@ export function registerGiteaIpc(): void {
|
|||||||
|
|
||||||
// Instant boot: the last persisted snapshot, shown before the fresh reconcile lands.
|
// Instant boot: the last persisted snapshot, shown before the fresh reconcile lands.
|
||||||
ipcMain.handle('gitea:boot', () => {
|
ipcMain.handle('gitea:boot', () => {
|
||||||
|
if (isDemo()) return { configured: true, cached: true, ...DEMO_SNAPSHOT, savedAt: DEMO_TODAY }
|
||||||
if (!getGiteaClient()) return { configured: false }
|
if (!getGiteaClient()) return { configured: false }
|
||||||
const persisted = bootSnapshot()
|
const persisted = bootSnapshot()
|
||||||
return persisted ? { configured: true, cached: true, ...persisted } : { configured: true, cached: false }
|
return persisted ? { configured: true, cached: true, ...persisted } : { configured: true, cached: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('gitea:reconcile', async () => {
|
ipcMain.handle('gitea:reconcile', async () => {
|
||||||
|
if (isDemo()) return { configured: true, stale: false, ...DEMO_SNAPSHOT }
|
||||||
const client = getGiteaClient()
|
const client = getGiteaClient()
|
||||||
if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} }
|
if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} }
|
||||||
try {
|
try {
|
||||||
@@ -313,6 +330,7 @@ export function registerGiteaIpc(): void {
|
|||||||
|
|
||||||
// Read the directive ledger from the pm-state repo (for the Directives screen).
|
// Read the directive ledger from the pm-state repo (for the Directives screen).
|
||||||
ipcMain.handle('pmstate:directives', async () => {
|
ipcMain.handle('pmstate:directives', async () => {
|
||||||
|
if (isDemo()) return { ok: true as const, directives: DEMO_DIRECTIVES }
|
||||||
const pm = getPmStateClient()
|
const pm = getPmStateClient()
|
||||||
if (!pm) return { ok: false as const, reason: 'unconfigured' as const }
|
if (!pm) return { ok: false as const, reason: 'unconfigured' as const }
|
||||||
try {
|
try {
|
||||||
@@ -324,6 +342,7 @@ export function registerGiteaIpc(): void {
|
|||||||
|
|
||||||
// Read the capacity config from the pm-state repo (for capacity-aware forecasts).
|
// Read the capacity config from the pm-state repo (for capacity-aware forecasts).
|
||||||
ipcMain.handle('pmstate:capacity', async () => {
|
ipcMain.handle('pmstate:capacity', async () => {
|
||||||
|
if (isDemo()) return { ok: true as const, members: DEMO_CAPACITY }
|
||||||
const pm = getPmStateClient()
|
const pm = getPmStateClient()
|
||||||
if (!pm) return { ok: false as const, reason: 'unconfigured' as const, members: [] }
|
if (!pm) return { ok: false as const, reason: 'unconfigured' as const, members: [] }
|
||||||
try {
|
try {
|
||||||
@@ -335,6 +354,7 @@ export function registerGiteaIpc(): void {
|
|||||||
|
|
||||||
// Assignable people (repo collaborators) for the Adjust dialog's assignee picker.
|
// Assignable people (repo collaborators) for the Adjust dialog's assignee picker.
|
||||||
ipcMain.handle('gitea:collaborators', async () => {
|
ipcMain.handle('gitea:collaborators', async () => {
|
||||||
|
if (isDemo()) return DEMO_COLLABORATORS
|
||||||
const client = getGiteaClient()
|
const client = getGiteaClient()
|
||||||
if (!client) return []
|
if (!client) return []
|
||||||
try {
|
try {
|
||||||
@@ -359,6 +379,38 @@ export function registerGiteaIpc(): void {
|
|||||||
return { ok: true as const }
|
return { ok: true as const }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Discover the owners + repos a token can reach, so onboarding can offer them
|
||||||
|
// as dropdowns. Token-scoped (not repo-scoped) — no owner/repo needed yet.
|
||||||
|
ipcMain.handle('config:discover', async (_event, conn: { baseUrl: string; token: string }) => {
|
||||||
|
try {
|
||||||
|
const found = await discoverRepos({ baseUrl: conn.baseUrl.replace(/\/+$/, ''), token: conn.token }, fetch)
|
||||||
|
return { ok: true as const, ...found }
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false as const, error: e instanceof GiteaApiError ? `${e.status}` : e instanceof Error ? e.message : String(e) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// First-run bootstrap: apply the label schema to the work repo and ensure the
|
||||||
|
// pm-state sidecar exists. Idempotent — safe to re-run. `underOrg` tells us
|
||||||
|
// whether `owner` is an org (vs the token's personal namespace).
|
||||||
|
ipcMain.handle(
|
||||||
|
'config:bootstrap',
|
||||||
|
async (_event, req: { baseUrl: string; token: string; owner: string; repo: string; underOrg: boolean }) => {
|
||||||
|
try {
|
||||||
|
const conn = { baseUrl: req.baseUrl.replace(/\/+$/, ''), token: req.token }
|
||||||
|
const owner = req.owner.trim()
|
||||||
|
const repo = req.repo.trim()
|
||||||
|
const client = createGiteaClient({ ...conn, owner, repo }, fetch)
|
||||||
|
const labels = await applySchemaLabels(client)
|
||||||
|
const pmStateRepo = `${repo}-pm-state`
|
||||||
|
const pmState = await ensurePmStateRepo(conn, { owner, repo: pmStateRepo, underOrg: req.underOrg }, fetch)
|
||||||
|
return { ok: true as const, labels, pmState, pmStateRepo }
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false as const, error: e instanceof GiteaApiError ? `${e.status}` : e instanceof Error ? e.message : String(e) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// Validate a token + repo before saving: any authed read on the repo proves access.
|
// Validate a token + repo before saving: any authed read on the repo proves access.
|
||||||
ipcMain.handle('config:test', async (_event, cfg: AppConfig) => {
|
ipcMain.handle('config:test', async (_event, cfg: AppConfig) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
86
apps/desktop/src/main/snapshot-store.test.ts
Normal file
86
apps/desktop/src/main/snapshot-store.test.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Purity / rebuild guarantee for the *shipped* durable cache (#30, D4). The app's
|
||||||
|
* on-disk reconcile cache is this JSON snapshot-store. The invariant: delete it
|
||||||
|
* and lose nothing — the durable truth is in gitea, the file is only a boot/offline
|
||||||
|
* mirror. This test deletes the real file and asserts the store degrades to
|
||||||
|
* "no cache" (null), which is what forces the next `getSnapshot` to reconcile
|
||||||
|
* fresh from gitea rather than serve stale or missing data.
|
||||||
|
*/
|
||||||
|
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// electron can't be imported outside the Electron runtime; the store only needs
|
||||||
|
// app.getPath for its default path, which every test overrides with an injected path.
|
||||||
|
vi.mock('electron', () => ({ app: { getPath: () => tmpdir() } }))
|
||||||
|
|
||||||
|
import { loadSnapshot, type PersistedSnapshot, saveSnapshot } from './snapshot-store.js'
|
||||||
|
|
||||||
|
const SNAP: Omit<PersistedSnapshot, 'savedAt'> = {
|
||||||
|
issues: [{ number: 1, title: 'An issue', state: 'open', labels: ['est/5d', 'p/1'] }],
|
||||||
|
milestones: [{ id: 7, title: 'P2' }],
|
||||||
|
deps: [{ issue: 1, dependsOn: 2 }],
|
||||||
|
timelines: { 1: [{ type: 'opened', at: '2026-07-01T00:00:00Z' }] },
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('snapshot-store purity (#30)', () => {
|
||||||
|
let dir: string | null = null
|
||||||
|
const path = () => {
|
||||||
|
if (!dir) dir = mkdtempSync(join(tmpdir(), 'commitea-snap-'))
|
||||||
|
return join(dir, 'commitea-snapshot.json')
|
||||||
|
}
|
||||||
|
afterEach(() => {
|
||||||
|
if (dir) rmSync(dir, { recursive: true, force: true })
|
||||||
|
dir = null
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips a saved snapshot', () => {
|
||||||
|
const p = path()
|
||||||
|
saveSnapshot(SNAP, '2026-07-09T00:00:00Z', p)
|
||||||
|
const back = loadSnapshot(p)
|
||||||
|
expect(back).toEqual({ ...SNAP, savedAt: '2026-07-09T00:00:00Z' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delete the cache file → load returns null (degrades to no-cache, not a crash)', () => {
|
||||||
|
const p = path()
|
||||||
|
saveSnapshot(SNAP, '2026-07-09T00:00:00Z', p)
|
||||||
|
expect(loadSnapshot(p)).not.toBeNull()
|
||||||
|
|
||||||
|
rmSync(p) // delete the durable cache
|
||||||
|
expect(existsSync(p)).toBe(false)
|
||||||
|
|
||||||
|
// The store must NOT throw and must report "no cache" so the next reconcile
|
||||||
|
// rebuilds from gitea. If this ever returned stale data or threw, D4 breaks.
|
||||||
|
expect(loadSnapshot(p)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a corrupt / partial file is treated as no-cache, never a crash', () => {
|
||||||
|
const p = path()
|
||||||
|
writeFileSync(p, '{ this is not json', 'utf8')
|
||||||
|
expect(loadSnapshot(p)).toBeNull()
|
||||||
|
// valid JSON but wrong shape (no issues array) is also rejected
|
||||||
|
writeFileSync(p, JSON.stringify({ savedAt: 'x' }), 'utf8')
|
||||||
|
expect(loadSnapshot(p)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-saving after a delete restores the cache — the rebuild is lossless', () => {
|
||||||
|
const p = path()
|
||||||
|
saveSnapshot(SNAP, '2026-07-09T00:00:00Z', p)
|
||||||
|
const before = loadSnapshot(p)
|
||||||
|
|
||||||
|
rmSync(p)
|
||||||
|
expect(loadSnapshot(p)).toBeNull()
|
||||||
|
|
||||||
|
// a resync would call saveSnapshot again with the freshly reconciled data
|
||||||
|
saveSnapshot(SNAP, '2026-07-09T01:00:00Z', p)
|
||||||
|
const after = loadSnapshot(p)
|
||||||
|
|
||||||
|
// same durable payload, only the savedAt marker differs
|
||||||
|
expect(after!.issues).toEqual(before!.issues)
|
||||||
|
expect(after!.milestones).toEqual(before!.milestones)
|
||||||
|
expect(after!.deps).toEqual(before!.deps)
|
||||||
|
expect(after!.timelines).toEqual(before!.timelines)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -26,10 +26,15 @@ function snapshotPath(): string {
|
|||||||
return join(app.getPath('userData'), 'commitea-snapshot.json')
|
return join(app.getPath('userData'), 'commitea-snapshot.json')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load the last persisted snapshot, or null if absent/corrupt. Never throws. */
|
/**
|
||||||
export function loadSnapshot(): PersistedSnapshot | null {
|
* Load the last persisted snapshot, or null if absent/corrupt. Never throws — a
|
||||||
|
* deleted or unreadable cache degrades to "no cache" (the purity guarantee, D4:
|
||||||
|
* the next reconcile rebuilds it from gitea). `path` is injectable for tests;
|
||||||
|
* production always uses the userData file.
|
||||||
|
*/
|
||||||
|
export function loadSnapshot(path: string = snapshotPath()): PersistedSnapshot | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(readFileSync(snapshotPath(), 'utf8')) as PersistedSnapshot
|
const parsed = JSON.parse(readFileSync(path, 'utf8')) as PersistedSnapshot
|
||||||
if (parsed && Array.isArray(parsed.issues)) return parsed
|
if (parsed && Array.isArray(parsed.issues)) return parsed
|
||||||
return null
|
return null
|
||||||
} catch {
|
} catch {
|
||||||
@@ -38,9 +43,9 @@ export function loadSnapshot(): PersistedSnapshot | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Persist a freshly reconciled snapshot. Best-effort — a write failure never breaks a reconcile. */
|
/** Persist a freshly reconciled snapshot. Best-effort — a write failure never breaks a reconcile. */
|
||||||
export function saveSnapshot(snap: Omit<PersistedSnapshot, 'savedAt'>, savedAt: string): void {
|
export function saveSnapshot(snap: Omit<PersistedSnapshot, 'savedAt'>, savedAt: string, path: string = snapshotPath()): void {
|
||||||
try {
|
try {
|
||||||
writeFileSync(snapshotPath(), JSON.stringify({ ...snap, savedAt }), 'utf8')
|
writeFileSync(path, JSON.stringify({ ...snap, savedAt }), 'utf8')
|
||||||
} catch {
|
} catch {
|
||||||
// disk full / permissions — the in-memory cache still works this session
|
// disk full / permissions — the in-memory cache still works this session
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ const api = {
|
|||||||
config: {
|
config: {
|
||||||
/** The saved config (never the token) — null when unset. */
|
/** The saved config (never the token) — null when unset. */
|
||||||
get: () => ipcRenderer.invoke('config:get'),
|
get: () => ipcRenderer.invoke('config:get'),
|
||||||
|
/** Discover the owners + repos a token can reach (for the onboarding pickers). */
|
||||||
|
discover: (conn: unknown) => ipcRenderer.invoke('config:discover', conn),
|
||||||
|
/** Apply the label schema + ensure the pm-state repo (first-run bootstrap). */
|
||||||
|
bootstrap: (req: unknown) => ipcRenderer.invoke('config:bootstrap', req),
|
||||||
/** Validate a token + repo before saving. */
|
/** Validate a token + repo before saving. */
|
||||||
test: (cfg: unknown) => ipcRenderer.invoke('config:test', cfg),
|
test: (cfg: unknown) => ipcRenderer.invoke('config:test', cfg),
|
||||||
/** Save config (token encrypted in main); takes effect without restart. */
|
/** Save config (token encrypted in main); takes effect without restart. */
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
import React, { useState } from 'react'
|
|
||||||
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Checkbox,
|
|
||||||
Dialog,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
Input,
|
|
||||||
Radio,
|
|
||||||
Select,
|
|
||||||
Switch,
|
|
||||||
Tabs,
|
|
||||||
Tag,
|
|
||||||
Toast,
|
|
||||||
Tooltip,
|
|
||||||
} from './ui/index.js'
|
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<section className="mt-9">
|
|
||||||
<h2 className="mb-3 font-mono text-xs uppercase tracking-wider text-ink-3">{title}</h2>
|
|
||||||
<div className="flex flex-wrap items-start gap-3">{children}</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 (
|
|
||||||
<main className="bg-paper-0 font-sans text-ink-1">
|
|
||||||
<div className="mx-auto max-w-[1120px]">
|
|
||||||
<header className="border-b-[3px] border-double border-ink-1 pb-4">
|
|
||||||
<h1 className="font-display text-4xl">Primitives</h1>
|
|
||||||
<p className="mt-1 font-mono text-sm text-ink-3">
|
|
||||||
15 components · toggle Evening service in the rail for dark
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<Section title="Button">
|
|
||||||
<Button variant="primary" icon="sparkles">
|
|
||||||
Brew plan
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary">Amend</Button>
|
|
||||||
<Button variant="ghost" icon="clock">
|
|
||||||
Defer
|
|
||||||
</Button>
|
|
||||||
<Button variant="danger" icon="trash-2">
|
|
||||||
Withdraw
|
|
||||||
</Button>
|
|
||||||
<Button variant="primary" size="sm" iconRight="arrow-right">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" disabled>
|
|
||||||
Disabled
|
|
||||||
</Button>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="IconButton">
|
|
||||||
<IconButton icon="pencil" label="Edit" />
|
|
||||||
<IconButton icon="copy" label="Copy" />
|
|
||||||
<IconButton icon="ellipsis" label="More" variant="outline" />
|
|
||||||
<IconButton icon="x" label="Close" size="sm" />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Badge">
|
|
||||||
<Badge tone="ok" dot>
|
|
||||||
ahead
|
|
||||||
</Badge>
|
|
||||||
<Badge tone="warn" dot>
|
|
||||||
at risk
|
|
||||||
</Badge>
|
|
||||||
<Badge tone="danger" dot>
|
|
||||||
behind
|
|
||||||
</Badge>
|
|
||||||
<Badge tone="info">steeping</Badge>
|
|
||||||
<Badge tone="neutral">triage</Badge>
|
|
||||||
<Badge tone="jade">on track</Badge>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Tag">
|
|
||||||
<Tag label="est/3d" />
|
|
||||||
<Tag label="est/8d" />
|
|
||||||
<Tag label="p/1" />
|
|
||||||
<Tag label="p/2" />
|
|
||||||
<Tag label="p/3" />
|
|
||||||
<Tag label="p/4" />
|
|
||||||
<Tag label="deadline/hard" />
|
|
||||||
<Tag label="p/1" onRemove={() => {}} />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Card">
|
|
||||||
<Card
|
|
||||||
overline="Now"
|
|
||||||
title="Gitea read client"
|
|
||||||
jade
|
|
||||||
actions={<IconButton icon="external-link" label="Open" size="sm" />}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button size="sm" variant="primary">
|
|
||||||
Start
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost">
|
|
||||||
Defer
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
style={{ width: 340 }}
|
|
||||||
>
|
|
||||||
<p className="font-agent text-base leading-relaxed">
|
|
||||||
The tap-root. Everything sinks into it — grab it first.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
<Card title="Plain card" style={{ width: 260 }}>
|
|
||||||
<p className="text-sm text-ink-2">Hairline border, whispered shadow.</p>
|
|
||||||
</Card>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Tabs">
|
|
||||||
<Tabs
|
|
||||||
items={[
|
|
||||||
{ id: 'board', label: 'Board', icon: 'square-kanban', count: 34 },
|
|
||||||
{ id: 'gantt', label: 'Gantt', icon: 'chart-no-axes-gantt' },
|
|
||||||
{ id: 'deps', label: 'Dependencies', icon: 'network', count: 51 },
|
|
||||||
]}
|
|
||||||
active={tab}
|
|
||||||
onChange={setTab}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Icon">
|
|
||||||
<Icon name="coffee" size={20} />
|
|
||||||
<Icon name="leaf" size={20} />
|
|
||||||
<Icon name="git-branch" size={20} />
|
|
||||||
<Icon name="milestone" size={20} />
|
|
||||||
<Icon name="sparkles" size={20} />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Input & Select">
|
|
||||||
<Input label="Repository" placeholder="christian/commitea" style={{ width: 240 }} />
|
|
||||||
<Input label="Search" icon="search" placeholder="Filter issues" style={{ width: 220 }} />
|
|
||||||
<Input label="Token" mono error="Connection failed" placeholder="tok_xxx" style={{ width: 220 }} />
|
|
||||||
<Select
|
|
||||||
label="Priority"
|
|
||||||
options={[
|
|
||||||
{ value: 'p/1', label: 'p/1 — urgent' },
|
|
||||||
{ value: 'p/2', label: 'p/2' },
|
|
||||||
{ value: 'p/3', label: 'p/3' },
|
|
||||||
]}
|
|
||||||
style={{ width: 200 }}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Checkbox · Radio · Switch">
|
|
||||||
<Checkbox label="Hard deadline" checked={checked} onChange={(e) => setChecked(e.target.checked)} />
|
|
||||||
<Checkbox label="Disabled" checked disabled onChange={() => {}} />
|
|
||||||
<Radio label="Option A" name="g" value="a" checked={radio === 'a'} onChange={() => setRadio('a')} />
|
|
||||||
<Radio label="Option B" name="g" value="b" checked={radio === 'b'} onChange={() => setRadio('b')} />
|
|
||||||
<Switch label="Live sync" checked={on} onChange={(e) => setOn(e.target.checked)} />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Feedback">
|
|
||||||
<Toast tone="ok" title="Plan brewed" onDismiss={() => {}}>
|
|
||||||
Milestone P2 lands Mar 3–12.
|
|
||||||
</Toast>
|
|
||||||
<Toast tone="warn" title="Stale blocker">
|
|
||||||
#9 has waited 6 days.
|
|
||||||
</Toast>
|
|
||||||
<Tooltip content="Writes to gitea, as you">
|
|
||||||
<Button variant="secondary" icon="info">
|
|
||||||
Hover me
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Button variant="primary" onClick={() => setDialogOpen(true)}>
|
|
||||||
Open dialog
|
|
||||||
</Button>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={dialogOpen}
|
|
||||||
onClose={() => setDialogOpen(false)}
|
|
||||||
title="Withdraw directive?"
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="ghost" onClick={() => setDialogOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="danger" onClick={() => setDialogOpen(false)}>
|
|
||||||
Withdraw
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<p>This removes the directive from the ledger. The schedule reverts to its prior state.</p>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import { COLUMNS, type BoardColumn, type BoardIssue, type DepsData, type GanttData, type IssueRef } from '../../data/fixtures.js'
|
import { type BoardColumn, type BoardIssue, type DepsData, type GanttData, type IssueRef } from '../../data/view-types.js'
|
||||||
import { Card, Tag, Badge, Tabs, IconButton, Input, Icon } from '../ui/index.js'
|
import { Card, Tag, Badge, Tabs, IconButton, Input, Icon } from '../ui/index.js'
|
||||||
import { EmptyState } from '../shell/states.js'
|
import { EmptyState } from '../shell/states.js'
|
||||||
import { GanttView } from './gantt-view.js'
|
import { GanttView } from './gantt-view.js'
|
||||||
import { DepsGraph } from './deps-graph.js'
|
import { DepsGraph } from './deps-graph.js'
|
||||||
|
|
||||||
// Board — kanban over inferred lifecycle, with Gantt/Dependencies stubs.
|
// Board — kanban over inferred lifecycle, with Gantt/Dependencies stubs.
|
||||||
// `columns` defaults to demo fixtures; the shell passes real reconciled data
|
// `columns` arrives from the shell as real reconciled data when gitea is
|
||||||
// when gitea is configured. `loading` covers the first reconcile.
|
// configured. `loading` covers the first reconcile.
|
||||||
export function BoardScreen({
|
export function BoardScreen({
|
||||||
onOpenIssue,
|
onOpenIssue,
|
||||||
columns = COLUMNS,
|
columns,
|
||||||
gantt,
|
gantt,
|
||||||
deps,
|
deps,
|
||||||
loading = false,
|
loading = false,
|
||||||
@@ -24,10 +24,12 @@ export function BoardScreen({
|
|||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = React.useState('board');
|
const [tab, setTab] = React.useState('board');
|
||||||
const [query, setQuery] = React.useState('');
|
const [query, setQuery] = React.useState('');
|
||||||
|
const cols = columns ?? [];
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
const filtered = columns.map((c) => ({ ...c, issues: q ? c.issues.filter((i) => (i.title + ' #' + i.id).toLowerCase().includes(q)) : c.issues }));
|
const filtered = cols.map((c) => ({ ...c, issues: q ? c.issues.filter((i) => (i.title + ' #' + i.id).toLowerCase().includes(q)) : c.issues }));
|
||||||
const anyMatch = filtered.some((c) => c.issues.length > 0);
|
const anyMatch = filtered.some((c) => c.issues.length > 0);
|
||||||
const openCount = columns.reduce((n, c) => n + (c.id === 'done' ? 0 : c.issues.length), 0);
|
const openCount = cols.reduce((n, c) => n + (c.id === 'done' ? 0 : c.issues.length), 0);
|
||||||
|
const totalIssues = cols.reduce((n, c) => n + c.issues.length, 0);
|
||||||
|
|
||||||
const IssueCard = ({ issue }: { issue: BoardIssue }) => (
|
const IssueCard = ({ issue }: { issue: BoardIssue }) => (
|
||||||
<div
|
<div
|
||||||
@@ -75,6 +77,10 @@ export function BoardScreen({
|
|||||||
<Icon name="loader-circle" size={16} />
|
<Icon name="loader-circle" size={16} />
|
||||||
<span style={{ font: 'var(--text-small)' }}>Reconciling with gitea…</span>
|
<span style={{ font: 'var(--text-small)' }}>Reconciling with gitea…</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : totalIssues === 0 ? (
|
||||||
|
<div style={{ flex: 1, border: '1px dashed var(--line-2)', borderRadius: 'var(--radius-3)' }}>
|
||||||
|
<EmptyState icon="inbox" title="The pot is empty" line="Nothing in the backlog yet." />
|
||||||
|
</div>
|
||||||
) : anyMatch ? (
|
) : anyMatch ? (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 12, alignItems: 'start', flex: 1, minHeight: 0, overflow: 'auto' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 12, alignItems: 'start', flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||||
{filtered.map((col) => (
|
{filtered.map((col) => (
|
||||||
@@ -90,7 +96,7 @@ export function BoardScreen({
|
|||||||
) : (
|
) : (
|
||||||
<div style={{ flex: 1, border: '1px dashed var(--line-2)', borderRadius: 'var(--radius-3)' }}>
|
<div style={{ flex: 1, border: '1px dashed var(--line-2)', borderRadius: 'var(--radius-3)' }}>
|
||||||
<EmptyState icon="search" title="Nothing by that name"
|
<EmptyState icon="search" title="Nothing by that name"
|
||||||
line={`The pot holds ${columns.reduce((n, c) => n + c.issues.length, 0)} issues; none of them answer to “${query.trim()}”.`} />
|
line={`The pot holds ${totalIssues} issues; none of them answer to “${query.trim()}”.`} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : tab === 'deps' ? (
|
) : tab === 'deps' ? (
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import { CALIBRATION, type CalibrationData } from '../../data/fixtures.js'
|
import { type CalibrationData } from '../../data/view-types.js'
|
||||||
|
import { EmptyState } from '../shell/states.js'
|
||||||
import { Badge, Card, Icon } from '../ui/index.js'
|
import { Badge, Card, Icon } from '../ui/index.js'
|
||||||
|
|
||||||
// Calibration report — estimate-vs-actual evidence behind the cones.
|
// Calibration report — estimate-vs-actual evidence behind the cones.
|
||||||
// `data` (real fit from closed-issue actuals) overrides the demo fixture.
|
// `data` (real fit from closed-issue actuals) drives the whole view.
|
||||||
export function CalibrationScreen({ onBack, data }: { onBack: () => void; data?: CalibrationData }) {
|
export function CalibrationScreen({ onBack, data }: { onBack: () => void; data?: CalibrationData }) {
|
||||||
const c = data ?? CALIBRATION
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="ruler"
|
||||||
|
title="Not enough history"
|
||||||
|
line="Close a few issues and I'll calibrate to your team's actuals."
|
||||||
|
action="Runway"
|
||||||
|
onAction={onBack}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const c = data
|
||||||
|
|
||||||
// scatter chart geometry
|
// scatter chart geometry
|
||||||
const W = 420,
|
const W = 420,
|
||||||
|
|||||||
@@ -1,68 +1,81 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import type { PublicConfig } from '../../global.js'
|
import type { DiscoverResult, PublicConfig } from '../../global.js'
|
||||||
import logoIcon from '../../design/assets/logo-icon.png'
|
import logoIcon from '../../design/assets/logo-icon.png'
|
||||||
import { Button, Card, Input } from '../ui/index.js'
|
import { Button, Card, Input, Select } from '../ui/index.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real onboarding — each teammate connects their own Gitea with a personal access
|
* Real onboarding — each teammate connects their own Gitea with a personal access
|
||||||
* token. The token is validated (an authed read on the repo) then saved encrypted
|
* token. The flow is progressive: enter the instance URL + PAT, and the app
|
||||||
* in the main process (never exposed to this renderer). Optional model URL wires
|
* discovers every owner + repo that token can reach, then you pick them from
|
||||||
* Reginald; leave it blank and chat simply stays off.
|
* dropdowns (no guessing owner/repo strings). Discovery also proves the token
|
||||||
|
* works, so any repo you can pick is one you can access. The token is saved
|
||||||
|
* encrypted in the main process (never exposed to this renderer). Optional model
|
||||||
|
* URL wires Reginald; leave it blank and chat simply stays off.
|
||||||
*/
|
*/
|
||||||
export function ConnectScreen({ onConnected, existing }: { onConnected: () => void; existing?: PublicConfig | null }) {
|
export function ConnectScreen({ onConnected, existing }: { onConnected: () => void; existing?: PublicConfig | null }) {
|
||||||
const [baseUrl, setBaseUrl] = React.useState(existing?.baseUrl ?? 'https://gitea.stephenmann.io')
|
const [baseUrl, setBaseUrl] = React.useState(existing?.baseUrl ?? 'https://gitea.stephenmann.io')
|
||||||
const [owner, setOwner] = React.useState(existing?.owner ?? '')
|
|
||||||
const [repo, setRepo] = React.useState(existing?.repo ?? '')
|
|
||||||
const [token, setToken] = React.useState('')
|
const [token, setToken] = React.useState('')
|
||||||
const [modelUrl, setModelUrl] = React.useState(existing?.modelUrl ?? '')
|
const [modelUrl, setModelUrl] = React.useState(existing?.modelUrl ?? '')
|
||||||
const [state, setState] = React.useState<'idle' | 'testing' | 'saving'>('idle')
|
|
||||||
const [error, setError] = React.useState<string | null>(null)
|
|
||||||
|
|
||||||
const cfg = () => ({ baseUrl, owner, repo, token, modelUrl: modelUrl.trim() || undefined })
|
const [owner, setOwner] = React.useState(existing?.owner ?? '')
|
||||||
const ready = baseUrl.trim() && owner.trim() && repo.trim() && (token.trim() || existing?.hasToken)
|
const [repo, setRepo] = React.useState(existing?.repo ?? '')
|
||||||
|
|
||||||
|
const [discovery, setDiscovery] = React.useState<'idle' | 'discovering' | 'done'>('idle')
|
||||||
|
const [found, setFound] = React.useState<{ owners: string[]; reposByOwner: Record<string, string[]> } | null>(null)
|
||||||
|
const [error, setError] = React.useState<string | null>(null)
|
||||||
|
const [state, setState] = React.useState<'idle' | 'saving'>('idle')
|
||||||
|
|
||||||
|
const mapError = (e?: string) =>
|
||||||
|
e === '401' || e === '403'
|
||||||
|
? 'The token was rejected — check it has repo + issue scopes.'
|
||||||
|
: e === '404'
|
||||||
|
? "Couldn't reach that Gitea — check the URL."
|
||||||
|
: `Discovery failed${e ? ` (${e})` : ''}.`
|
||||||
|
|
||||||
|
// Discover the owners + repos this token can see. Triggered when the token
|
||||||
|
// field blurs (or on Enter), once a URL + token are both present.
|
||||||
|
const discover = async () => {
|
||||||
|
if (!baseUrl.trim() || !token.trim() || discovery === 'discovering') return
|
||||||
|
setError(null)
|
||||||
|
setDiscovery('discovering')
|
||||||
|
const res: DiscoverResult = await window.commitea.config
|
||||||
|
.discover({ baseUrl: baseUrl.trim(), token: token.trim() })
|
||||||
|
.catch(() => ({ ok: false as const, error: 'unreachable' }))
|
||||||
|
if (!res.ok) {
|
||||||
|
setDiscovery('idle')
|
||||||
|
setFound(null)
|
||||||
|
setError(mapError(res.error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFound({ owners: res.owners, reposByOwner: res.reposByOwner })
|
||||||
|
setDiscovery('done')
|
||||||
|
// keep the existing selection if it's still valid; else default to the token
|
||||||
|
// owner (res.user) and its first repo.
|
||||||
|
const nextOwner = res.owners.includes(owner) ? owner : (res.owners.includes(res.user) ? res.user : res.owners[0] ?? '')
|
||||||
|
setOwner(nextOwner)
|
||||||
|
const repos = res.reposByOwner[nextOwner] ?? []
|
||||||
|
setRepo(repos.includes(repo) ? repo : repos[0] ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option lists. Before discovery, fall back to the existing config so editing
|
||||||
|
// stays functional without re-entering the token.
|
||||||
|
const ownerOptions = found?.owners ?? (existing?.owner ? [existing.owner] : [])
|
||||||
|
const repoOptions = found?.reposByOwner[owner] ?? (owner && owner === existing?.owner && existing?.repo ? [existing.repo] : [])
|
||||||
|
|
||||||
|
const pickerReady = discovery === 'done' || (!!existing && !token.trim())
|
||||||
|
const ready = baseUrl.trim() && owner && repo && (token.trim() || existing?.hasToken)
|
||||||
|
|
||||||
const connect = async () => {
|
const connect = async () => {
|
||||||
setError(null)
|
setError(null)
|
||||||
setState('testing')
|
|
||||||
// reuse the saved token when editing and the field is left blank
|
|
||||||
const payload = token.trim() ? cfg() : { ...cfg(), token: '' }
|
|
||||||
if (token.trim()) {
|
|
||||||
const test = await window.commitea.config.test(cfg()).catch(() => ({ ok: false, error: 'unreachable' }))
|
|
||||||
if (!test.ok) {
|
|
||||||
setState('idle')
|
|
||||||
setError(
|
|
||||||
test.error === '404'
|
|
||||||
? "Couldn't find that repo — check owner/name and that the token can see it."
|
|
||||||
: test.error === '401' || test.error === '403'
|
|
||||||
? 'The token was rejected — check it has repo + issue scopes.'
|
|
||||||
: `Connection failed${test.error ? ` (${test.error})` : ''}.`,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setState('saving')
|
setState('saving')
|
||||||
await window.commitea.config.set(payload)
|
// discovery already validated the token + that this repo is accessible; when
|
||||||
|
// editing without a new token, reuse the saved one (empty token = keep).
|
||||||
|
await window.commitea.config.set({ baseUrl: baseUrl.trim(), owner, repo, token: token.trim(), modelUrl: modelUrl.trim() || undefined })
|
||||||
onConnected()
|
onConnected()
|
||||||
}
|
}
|
||||||
|
|
||||||
const field = (
|
const placeholder = (label: string) => [{ value: '', label }]
|
||||||
label: string,
|
|
||||||
value: string,
|
|
||||||
set: (v: string) => void,
|
|
||||||
opts: { placeholder?: string; type?: string; hint?: string } = {},
|
|
||||||
) => (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
|
||||||
<Input
|
|
||||||
label={label}
|
|
||||||
value={value}
|
|
||||||
type={opts.type}
|
|
||||||
placeholder={opts.placeholder}
|
|
||||||
onChange={(e) => set(e.target.value)}
|
|
||||||
/>
|
|
||||||
{opts.hint ? <span style={{ font: 'var(--text-caption)', color: 'var(--ink-3)' }}>{opts.hint}</span> : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -87,19 +100,76 @@ export function ConnectScreen({ onConnected, existing }: { onConnected: () => vo
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
{field('Gitea URL', baseUrl, setBaseUrl, { placeholder: 'https://gitea.example.com' })}
|
<Input
|
||||||
|
label="Gitea URL"
|
||||||
|
value={baseUrl}
|
||||||
|
placeholder="https://gitea.example.com"
|
||||||
|
onChange={(e) => {
|
||||||
|
setBaseUrl(e.target.value)
|
||||||
|
setDiscovery('idle')
|
||||||
|
setFound(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Access token"
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
placeholder={existing?.hasToken ? '•••••••• (saved — enter a new one to change repos)' : 'gitea PAT · scopes: repo + issue'}
|
||||||
|
hint={
|
||||||
|
discovery === 'discovering'
|
||||||
|
? 'Discovering your repositories…'
|
||||||
|
: discovery === 'done' && found
|
||||||
|
? `Found ${found.owners.reduce((n, o) => n + (found.reposByOwner[o]?.length ?? 0), 0)} repositories across ${found.owners.length} owner${found.owners.length === 1 ? '' : 's'}.`
|
||||||
|
: 'Paste your token, then tab out to load your repositories.'
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
setToken(e.target.value)
|
||||||
|
setDiscovery('idle')
|
||||||
|
}}
|
||||||
|
onBlur={() => void discover()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
void discover()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
{field('Owner', owner, setOwner, { placeholder: 'your-org' })}
|
<Select
|
||||||
{field('Repository', repo, setRepo, { placeholder: 'your-repo' })}
|
label="Owner"
|
||||||
|
value={owner}
|
||||||
|
disabled={!pickerReady || ownerOptions.length === 0}
|
||||||
|
options={
|
||||||
|
ownerOptions.length
|
||||||
|
? ownerOptions.map((o) => ({ value: o, label: o }))
|
||||||
|
: placeholder(discovery === 'discovering' ? 'Loading…' : 'Connect token first')
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
const o = e.target.value
|
||||||
|
setOwner(o)
|
||||||
|
const repos = found?.reposByOwner[o] ?? []
|
||||||
|
setRepo(repos[0] ?? '')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Repository"
|
||||||
|
value={repo}
|
||||||
|
disabled={!pickerReady || repoOptions.length === 0}
|
||||||
|
options={
|
||||||
|
repoOptions.length ? repoOptions.map((r) => ({ value: r, label: r })) : placeholder('—')
|
||||||
|
}
|
||||||
|
onChange={(e) => setRepo(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{field('Access token', token, setToken, {
|
|
||||||
type: 'password',
|
<Input
|
||||||
placeholder: existing?.hasToken ? '•••••••• (saved — leave blank to keep)' : 'gitea PAT · scopes: repo + issue',
|
label="Model URL"
|
||||||
})}
|
value={modelUrl}
|
||||||
{field('Model URL', modelUrl, setModelUrl, {
|
placeholder="http://localhost:1234/v1"
|
||||||
placeholder: 'http://localhost:1234/v1',
|
hint="Optional — an OpenAI-compatible endpoint for Reginald. Leave blank to keep chat off."
|
||||||
hint: 'Optional — an OpenAI-compatible endpoint for Reginald. Leave blank to keep chat off.',
|
onChange={(e) => setModelUrl(e.target.value)}
|
||||||
})}
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -108,7 +178,7 @@ export function ConnectScreen({ onConnected, existing }: { onConnected: () => vo
|
|||||||
|
|
||||||
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
|
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
|
||||||
<Button onClick={() => void connect()} disabled={!ready || state !== 'idle'}>
|
<Button onClick={() => void connect()} disabled={!ready || state !== 'idle'}>
|
||||||
{state === 'testing' ? 'Checking…' : state === 'saving' ? 'Connecting…' : existing ? 'Save' : 'Connect'}
|
{state === 'saving' ? 'Connecting…' : existing ? 'Save' : 'Connect'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import { DEPS, type DepsData, type IssueRef } from '../../data/fixtures.js'
|
import { type DepsData, type IssueRef } from '../../data/view-types.js'
|
||||||
import { Tag, Icon } from '../ui/index.js'
|
import { Tag, Icon } from '../ui/index.js'
|
||||||
|
import { EmptyState } from '../shell/states.js'
|
||||||
|
|
||||||
// Dependency graph drill-in — layered DAG, critical path in spruce
|
// Dependency graph drill-in — layered DAG, critical path in spruce
|
||||||
export function DepsGraph({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef) => void; data?: DepsData }) {
|
export function DepsGraph({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef) => void; data?: DepsData }) {
|
||||||
const g = data ?? DEPS
|
if (!data || data.nodes.length === 0) {
|
||||||
|
return <EmptyState icon="share-2" title="No dependencies" line="Nothing blocks anything yet." />
|
||||||
|
}
|
||||||
|
const g = data
|
||||||
|
|
||||||
const PAD = 14, COLW = 206, ROWH = 106, NW = 176, NH = 84
|
const PAD = 14, COLW = 206, ROWH = 106, NW = 176, NH = 84
|
||||||
const X = (c: number) => PAD + c * COLW
|
const X = (c: number) => PAD + c * COLW
|
||||||
@@ -36,7 +40,7 @@ export function DepsGraph({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef
|
|||||||
diagnosis: { icon: 'circle-dashed', color: 'var(--ink-3)', label: 'diagnosis' },
|
diagnosis: { icon: 'circle-dashed', color: 'var(--ink-3)', label: 'diagnosis' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const Node = ({ n }: { n: (typeof DEPS.nodes)[number] }) => {
|
const Node = ({ n }: { n: DepsData['nodes'][number] }) => {
|
||||||
const st = STATES[n.state]
|
const st = STATES[n.state]
|
||||||
const crit = g.critical.includes(n.id)
|
const crit = g.critical.includes(n.id)
|
||||||
return (
|
return (
|
||||||
@@ -121,10 +125,6 @@ export function DepsGraph({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
|
|
||||||
Four issues sit on the critical path, and #87 is the cork in the bottle. Remove it and everything pours.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,133 +1,124 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import { DIRECTIVES, type DirectivePending, type DirectiveEntry } from '../../data/fixtures.js'
|
import { Badge, Card, Icon } from '../ui/index.js'
|
||||||
import { Card, Button, Badge, Icon } from '../ui/index.js'
|
import { EmptyState } from '../shell/states.js'
|
||||||
|
|
||||||
// Directive log — append-only ledger + the consequence diff (propose-approve)
|
/** A ledger row, projected from a live DirectiveRecord. */
|
||||||
|
interface LedgerEntry {
|
||||||
|
seq: number
|
||||||
|
when: string
|
||||||
|
what: string
|
||||||
|
why: string
|
||||||
|
kind: string
|
||||||
|
status: 'applied' | 'amended' | 'withdrawn' | 'proposed'
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<LedgerEntry['status'], { tone: 'ok' | 'neutral' | 'info'; label: string }> = {
|
||||||
|
applied: { tone: 'ok', label: 'applied' },
|
||||||
|
amended: { tone: 'info', label: 'amended' },
|
||||||
|
withdrawn: { tone: 'neutral', label: 'withdrawn' },
|
||||||
|
proposed: { tone: 'info', label: 'proposed' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directive ledger — the append-only record of every time a human overruled the
|
||||||
|
* scheduler (who/when/what/why), read live from the pm-state repo. Directives are
|
||||||
|
* given in chat and recorded here; this screen only reads them.
|
||||||
|
*/
|
||||||
export function DirectivesScreen() {
|
export function DirectivesScreen() {
|
||||||
const [pending, setPending] = React.useState<DirectivePending | null>(DIRECTIVES.pending)
|
const [entries, setEntries] = React.useState<LedgerEntry[] | null>(null) // null = loading
|
||||||
const [entries, setEntries] = React.useState<DirectiveEntry[]>(DIRECTIVES.entries)
|
|
||||||
|
|
||||||
// Real ledger from the pm-state repo, when it exists — else the fixture demo.
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let alive = true
|
let alive = true
|
||||||
window.commitea.pmstate
|
window.commitea.pmstate
|
||||||
.directives()
|
.directives()
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!alive || !r.ok || r.directives.length === 0) return
|
if (!alive) return
|
||||||
setPending(null)
|
if (!r.ok) {
|
||||||
|
setEntries([])
|
||||||
|
return
|
||||||
|
}
|
||||||
setEntries(
|
setEntries(
|
||||||
r.directives
|
r.directives
|
||||||
.slice()
|
.slice()
|
||||||
.reverse()
|
.reverse() // newest first
|
||||||
.map((d) => ({
|
.map((d) => ({
|
||||||
seq: d.seq,
|
seq: d.seq,
|
||||||
who: 'You',
|
|
||||||
when: new Date(d.ts).toLocaleDateString(),
|
when: new Date(d.ts).toLocaleDateString(),
|
||||||
what: d.quote,
|
what: d.quote,
|
||||||
why: d.rationale ?? '',
|
why: d.rationale ?? '',
|
||||||
status: d.status === 'accepted' ? 'applied' : d.status === 'withdrawn' ? 'withdrawn' : 'superseded',
|
kind: d.kind,
|
||||||
consequence: '',
|
status:
|
||||||
|
d.status === 'accepted'
|
||||||
|
? 'applied'
|
||||||
|
: d.status === 'amended'
|
||||||
|
? 'amended'
|
||||||
|
: d.status === 'withdrawn'
|
||||||
|
? 'withdrawn'
|
||||||
|
: 'proposed',
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {
|
||||||
|
if (alive) setEntries([])
|
||||||
|
})
|
||||||
return () => {
|
return () => {
|
||||||
alive = false
|
alive = false
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const resolve = (status: string) => {
|
|
||||||
setEntries((e) => [{
|
|
||||||
seq: pending!.seq, who: pending!.who, when: pending!.when, what: pending!.what, why: 'pilot demo on the 14th',
|
|
||||||
status, consequence: status === 'applied' ? '#78 +5d · Beta 80% Mar 5–14' : 'withdrawn before apply',
|
|
||||||
}, ...e]);
|
|
||||||
setPending(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toneColor: Record<string, string> = { ok: 'var(--ok)', warn: 'var(--warn)', info: 'var(--info)', danger: 'var(--danger)' };
|
|
||||||
const statusBadge: Record<string, { tone: 'ok' | 'neutral' | 'info'; label: string }> = {
|
|
||||||
applied: { tone: 'ok', label: 'applied' },
|
|
||||||
withdrawn: { tone: 'neutral', label: 'withdrawn' },
|
|
||||||
superseded: { tone: 'info', label: 'superseded' },
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 760, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ maxWidth: 760, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
|
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
|
||||||
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Directives</h1>
|
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Directives</h1>
|
||||||
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>append-only · JSONL in pm-state · who, when, what, why</p>
|
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>
|
||||||
|
append-only · JSONL in pm-state · who, when, what, why
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{pending ? (
|
{entries === null ? null : entries.length === 0 ? (
|
||||||
<Card overline={`Awaiting your word · directive #00${pending.seq}`} title="Consequence diff" jade
|
<EmptyState
|
||||||
footer={<>
|
icon="flag"
|
||||||
<Button onClick={() => resolve('applied')}>Make it so</Button>
|
title="No directives yet"
|
||||||
<Button variant="secondary" onClick={() => {}}>Amend</Button>
|
line="When you overrule the scheduler in chat, it goes on the record here — who, when, what, why."
|
||||||
<Button variant="ghost" onClick={() => resolve('withdrawn')}>Withdraw</Button>
|
/>
|
||||||
</>}>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
||||||
<p style={{ font: 'var(--text-body)', margin: 0, color: 'var(--ink-2)' }}>
|
|
||||||
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>{pending.who}</span>
|
|
||||||
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-3)' }}> · {pending.when}</span>
|
|
||||||
<br />“{pending.what}”
|
|
||||||
</p>
|
|
||||||
<div style={{ borderTop: '1px solid var(--line-1)' }}>
|
|
||||||
{pending.diff.map((r) => (
|
|
||||||
<div key={r.change} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 0', borderBottom: '1px solid var(--line-1)' }}>
|
|
||||||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: toneColor[r.tone], flexShrink: 0 }}></span>
|
|
||||||
<span style={{ font: 'var(--text-small)', color: 'var(--ink-1)', flex: 1 }}>{r.change}</span>
|
|
||||||
<span style={{ font: '400 12px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>
|
|
||||||
{r.from} <span style={{ color: 'var(--ink-2)' }}>→</span> <span style={{ color: r.tone === 'warn' ? 'var(--warn)' : 'var(--ink-1)' }}>{r.to}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
|
|
||||||
Cheap, as consequences go. Shall I make it so?
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, font: 'var(--text-small)', color: 'var(--ink-3)', padding: '2px 2px' }}>
|
|
||||||
<Icon name="circle-check" size={14} style={{ color: 'var(--ok)' }} />
|
|
||||||
Nothing awaits your word. Directives are given in chat; consequences appear here first.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card overline="The ledger" flush>
|
<Card overline="The ledger" flush>
|
||||||
<div>
|
<div>
|
||||||
{entries.map((e, i) => {
|
{entries.map((e, i) => {
|
||||||
const sb = statusBadge[e.status];
|
const sb = STATUS_BADGE[e.status]
|
||||||
return (
|
return (
|
||||||
<div key={e.seq} style={{ display: 'flex', gap: 14, padding: '14px 20px', borderTop: i === 0 ? 'none' : '1px solid var(--line-1)' }}>
|
<div
|
||||||
|
key={e.seq}
|
||||||
|
style={{ display: 'flex', gap: 14, padding: '14px 20px', borderTop: i === 0 ? 'none' : '1px solid var(--line-1)' }}
|
||||||
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||||
<span style={{ font: '500 11px var(--font-mono)', color: 'var(--ink-3)' }}>#00{e.seq}</span>
|
<span style={{ font: '500 11px var(--font-mono)', color: 'var(--ink-3)' }}>#{String(e.seq).padStart(3, '0')}</span>
|
||||||
<span style={{ width: 1, flex: 1, background: 'var(--line-1)' }}></span>
|
<span style={{ width: 1, flex: 1, background: 'var(--line-1)' }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<span style={{
|
<Badge>{e.kind}</Badge>
|
||||||
width: 20, height: 20, borderRadius: '50%', background: 'var(--spruce-2)', color: 'var(--accent-text)',
|
|
||||||
font: '600 8.5px/20px var(--font-sans)', textAlign: 'center', flexShrink: 0,
|
|
||||||
}}>{e.who.split(' ').map((w: string) => w[0]).join('')}</span>
|
|
||||||
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)', whiteSpace: 'nowrap' }}>{e.who}</span>
|
|
||||||
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>{e.when}</span>
|
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>{e.when}</span>
|
||||||
{e.why ? <span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>· why: {e.why}</span> : null}
|
{e.why ? (
|
||||||
<span style={{ marginLeft: 'auto' }}><Badge tone={sb.tone}>{sb.label}</Badge></span>
|
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>· why: {e.why}</span>
|
||||||
|
) : null}
|
||||||
|
<span style={{ marginLeft: 'auto' }}>
|
||||||
|
<Badge tone={sb.tone}>{sb.label}</Badge>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ font: 'var(--text-body)', color: 'var(--ink-1)', margin: 0 }}>“{e.what}”</p>
|
<p style={{ font: 'var(--text-body)', color: 'var(--ink-1)', margin: 0 }}>“{e.what}”</p>
|
||||||
<p style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)', margin: 0 }}>{e.consequence}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>
|
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>
|
||||||
Entries are never edited. Corrections are new entries — the ledger remembers everything, politely.
|
Entries are never edited. Corrections are new entries — the ledger remembers everything, politely.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import { FOCUS, type FocusIssue, type IssueRef, TODAY } from '../../data/fixtures.js'
|
import { type FocusIssue, type IssueRef } from '../../data/view-types.js'
|
||||||
import { type ForecastView, type FocusView } from '../../lib/backlog.js'
|
import { type ForecastView, type FocusView } from '../../lib/backlog.js'
|
||||||
import { BurnUpCone } from '../charts/chart.js'
|
import { BurnUpCone } from '../charts/chart.js'
|
||||||
|
import { EmptyState } from '../shell/states.js'
|
||||||
import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js'
|
import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,7 +20,6 @@ export function FocusScreen({
|
|||||||
focus?: FocusView
|
focus?: FocusView
|
||||||
forecast?: ForecastView
|
forecast?: ForecastView
|
||||||
}) {
|
}) {
|
||||||
const view: FocusView = focus ?? { now: FOCUS.now, next: FOCUS.next, later: FOCUS.later }
|
|
||||||
const FocusRow = ({ slot, issue, jade }: { slot: string; issue: FocusIssue; jade?: boolean }) => (
|
const FocusRow = ({ slot, issue, jade }: { slot: string; issue: FocusIssue; jade?: boolean }) => (
|
||||||
<Card
|
<Card
|
||||||
overline={slot}
|
overline={slot}
|
||||||
@@ -66,6 +66,16 @@ export function FocusScreen({
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!focus) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="coffee"
|
||||||
|
title="Nothing to pour"
|
||||||
|
line="Capture some work, or enjoy the silence — it never lasts."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
<header
|
<header
|
||||||
@@ -80,41 +90,31 @@ export function FocusScreen({
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Morning service</h1>
|
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Morning service</h1>
|
||||||
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0', whiteSpace: 'nowrap' }}>
|
|
||||||
{TODAY} · reconcile 3.2s
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge tone="ok" dot>
|
|
||||||
ahead of forecast
|
|
||||||
</Badge>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr 1fr', gap: 14, alignItems: 'start' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr 1fr', gap: 14, alignItems: 'start' }}>
|
||||||
{view.now ? <FocusRow slot="Now" issue={view.now} jade /> : null}
|
{focus.now ? <FocusRow slot="Now" issue={focus.now} jade /> : null}
|
||||||
{view.next ? <FocusRow slot="Next" issue={view.next} /> : null}
|
{focus.next ? <FocusRow slot="Next" issue={focus.next} /> : null}
|
||||||
{view.later ? <FocusRow slot="Later" issue={view.later} /> : null}
|
{focus.later ? <FocusRow slot="Later" issue={focus.later} /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{forecast ? (
|
||||||
<Card
|
<Card
|
||||||
overline={forecast ? 'Backlog · open scope' : 'Milestone · Beta'}
|
overline="Backlog · open scope"
|
||||||
title={
|
title={
|
||||||
forecast ? (
|
|
||||||
<>80% of the open backlog lands by <span style={{ whiteSpace: 'nowrap' }}>{forecast.rangeLabel}</span></>
|
<>80% of the open backlog lands by <span style={{ whiteSpace: 'nowrap' }}>{forecast.rangeLabel}</span></>
|
||||||
) : (
|
|
||||||
<>80% this lands <span style={{ whiteSpace: 'nowrap' }}>Mar 3–12</span></>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
actions={<IconButton icon="chart-line" label="Open runway" size="sm" />}
|
actions={<IconButton icon="chart-line" label="Open runway" size="sm" />}
|
||||||
>
|
>
|
||||||
<BurnUpCone data={forecast?.cone} />
|
<BurnUpCone data={forecast.cone} />
|
||||||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
|
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
|
||||||
{forecast
|
{forecast.coldStart
|
||||||
? forecast.coldStart
|
|
||||||
? `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope. Cold-start priors — ${forecast.calibratedN}/20 estimated closes so far; the cone tightens as the team closes work.`
|
? `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope. Cold-start priors — ${forecast.calibratedN}/20 estimated closes so far; the cone tightens as the team closes work.`
|
||||||
: `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope, calibrated on ${forecast.calibratedN} closed ${forecast.calibratedN === 1 ? 'issue' : 'issues'} of your own.`
|
: `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope, calibrated on ${forecast.calibratedN} closed ${forecast.calibratedN === 1 ? 'issue' : 'issues'} of your own.`}
|
||||||
: 'The cone has narrowed since Friday. I’m quietly pleased.'}
|
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import { GANTT, type GanttData, type IssueRef } from '../../data/fixtures.js'
|
import { type GanttData, type IssueRef } from '../../data/view-types.js'
|
||||||
|
import { EmptyState } from '../shell/states.js'
|
||||||
import { Icon } from '../ui/index.js'
|
import { Icon } from '../ui/index.js'
|
||||||
|
|
||||||
// Gantt drill-in — scheduler-derived bars, critical chain, 80% forecast tails
|
// Gantt drill-in — scheduler-derived bars, critical chain, 80% forecast tails
|
||||||
export function GanttView({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef) => void; data?: GanttData }) {
|
export function GanttView({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef) => void; data?: GanttData }) {
|
||||||
const g = data ?? GANTT;
|
if (!data || data.rows.length === 0) {
|
||||||
|
return <EmptyState icon="calendar" title="No schedule yet" line="Estimates and milestones will fill this in." />;
|
||||||
|
}
|
||||||
|
const g = data;
|
||||||
|
|
||||||
const LABELW = 232, DAYW = 21, ROWH = 36, HEADH = 30;
|
const LABELW = 232, DAYW = 21, ROWH = 36, HEADH = 30;
|
||||||
const chartW = g.days * DAYW;
|
const chartW = g.days * DAYW;
|
||||||
@@ -96,10 +100,6 @@ export function GanttView({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
|
|
||||||
The path holds if #87 lands by Wednesday. The dotted tails are your own history, wagging.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import { INBOX, type InboxItem, type IssueRef } from '../../data/fixtures.js'
|
import { type InboxItem, type IssueRef } from '../../data/view-types.js'
|
||||||
|
import { EmptyState } from '../shell/states.js'
|
||||||
import { Button, Card, Icon, Tabs } from '../ui/index.js'
|
import { Button, Card, Icon, Tabs } from '../ui/index.js'
|
||||||
|
|
||||||
// Inbox — Reginald only rings the bell when it matters
|
// Inbox — Reginald only rings the bell when it matters
|
||||||
@@ -17,9 +18,14 @@ export function InboxScreen({
|
|||||||
setReadIds: React.Dispatch<React.SetStateAction<number[]>>
|
setReadIds: React.Dispatch<React.SetStateAction<number[]>>
|
||||||
data?: InboxItem[]
|
data?: InboxItem[]
|
||||||
}) {
|
}) {
|
||||||
const all = data ?? INBOX
|
|
||||||
const [tab, setTab] = React.useState('all')
|
const [tab, setTab] = React.useState('all')
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return <EmptyState icon="bell" title="Inbox zero" line="Nothing needs your eyes right now." />
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = data
|
||||||
|
|
||||||
const isRead = (n: InboxItem) => !n.unread || readIds.includes(n.id)
|
const isRead = (n: InboxItem) => !n.unread || readIds.includes(n.id)
|
||||||
const unreadCount = all.filter((n) => !isRead(n)).length
|
const unreadCount = all.filter((n) => !isRead(n)).length
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
summarizeChange,
|
summarizeChange,
|
||||||
} from '@commitea/core'
|
} from '@commitea/core'
|
||||||
|
|
||||||
import { ISSUE_DETAIL, type IssueDetail, type IssueRef } from '../../data/fixtures.js'
|
import { type IssueDetail, type IssueRef } from '../../data/view-types.js'
|
||||||
import { Badge, Button, Card, Dialog, Icon, Select, Tag } from '../ui/index.js'
|
import { Badge, Button, Card, Dialog, Icon, Select, Tag } from '../ui/index.js'
|
||||||
|
|
||||||
const NONE = '—'
|
const NONE = '—'
|
||||||
@@ -38,7 +38,7 @@ export function IssueScreen({
|
|||||||
currentAssignee?: string | null
|
currentAssignee?: string | null
|
||||||
currentMilestoneId?: number | null
|
currentMilestoneId?: number | null
|
||||||
}) {
|
}) {
|
||||||
const det: IssueDetail = detail ?? ISSUE_DETAIL[issue.id] ?? {
|
const det: IssueDetail = detail ?? {
|
||||||
state: 'triage',
|
state: 'triage',
|
||||||
assignee: 'Stephen',
|
assignee: 'Stephen',
|
||||||
milestone: 'Beta',
|
milestone: 'Beta',
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import { COLUMNS, type IssueRef } from '../../data/fixtures.js'
|
import { type IssueRef } from '../../data/view-types.js'
|
||||||
import { type MilestoneView } from '../../lib/backlog.js'
|
import { type MilestoneView } from '../../lib/backlog.js'
|
||||||
import { BurnUpCone } from '../charts/chart.js'
|
import { BurnUpCone } from '../charts/chart.js'
|
||||||
|
import { EmptyState } from '../shell/states.js'
|
||||||
import { Badge, Button, Card, Icon, Tag } from '../ui/index.js'
|
import { Badge, Button, Card, Icon, Tag } from '../ui/index.js'
|
||||||
|
|
||||||
// Milestone detail — scope, cone, issues; forecasts stay ranges.
|
// Milestone detail — scope, cone, issues; forecasts stay ranges.
|
||||||
// `data` (real milestone forecast) overrides the demo fixture when present.
|
|
||||||
export function MilestoneScreen({
|
export function MilestoneScreen({
|
||||||
onBack,
|
onBack,
|
||||||
onOpenIssue,
|
onOpenIssue,
|
||||||
@@ -16,28 +16,24 @@ export function MilestoneScreen({
|
|||||||
onOpenIssue: (issue: IssueRef) => void
|
onOpenIssue: (issue: IssueRef) => void
|
||||||
data?: MilestoneView
|
data?: MilestoneView
|
||||||
}) {
|
}) {
|
||||||
const cols = COLUMNS
|
if (!data) {
|
||||||
const byState = (ids: number[]) =>
|
return (
|
||||||
cols.flatMap((c) => c.issues.map((i) => ({ ...i, col: c.label }))).filter((i) => ids.includes(i.id))
|
<EmptyState
|
||||||
|
icon="flag"
|
||||||
|
title="Milestone not found"
|
||||||
|
line="It may have closed or been renamed."
|
||||||
|
action="Back to Runway"
|
||||||
|
onAction={onBack}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const fixtureGroups = [
|
const groups = data.groups.map((g) => ({ label: g.label, issues: g.issues, muted: g.label === 'Done' }))
|
||||||
{ label: 'Steeping', issues: byState([87, 84]) },
|
const name = data.name
|
||||||
{ label: 'In review', issues: byState([92]) },
|
const dueLine = `milestone · due ${data.due} · ${data.soft ? 'soft — scope may flex' : 'hard deadline'}`
|
||||||
{ label: 'Queued', issues: byState([102, 103, 99, 96, 78]) },
|
const forecastLabel = data.forecastRange ? `80% ${data.forecastRange}` : 'all shipped'
|
||||||
{ label: 'Done', issues: byState([71, 69, 65]), muted: true },
|
const scopeStat = `${data.scopeCount} issues · est ${data.scopeEstDays}d`
|
||||||
]
|
const doneStat = `${data.doneCount} · ${data.donePct}%`
|
||||||
|
|
||||||
// real or demo, in one shape the render loop understands
|
|
||||||
const groups = data
|
|
||||||
? data.groups.map((g) => ({ label: g.label, issues: g.issues, muted: g.label === 'Done' }))
|
|
||||||
: fixtureGroups
|
|
||||||
const name = data ? data.name : 'Beta'
|
|
||||||
const dueLine = data
|
|
||||||
? `milestone · due ${data.due} · ${data.soft ? 'soft — scope may flex' : 'hard deadline'}`
|
|
||||||
: 'milestone · due Mar 15 · soft — scope may flex'
|
|
||||||
const forecastLabel = data ? (data.forecastRange ? `80% ${data.forecastRange}` : 'all shipped') : '80% Mar 3–12'
|
|
||||||
const scopeStat = data ? `${data.scopeCount} issues · est ${data.scopeEstDays}d` : '42 issues · est 61d'
|
|
||||||
const doneStat = data ? `${data.doneCount} · ${data.donePct}%` : '24 · 57%'
|
|
||||||
|
|
||||||
const Stat = ({ label, value, tone }: { label: string; value: string; tone?: string }) => (
|
const Stat = ({ label, value, tone }: { label: string; value: string; tone?: string }) => (
|
||||||
<div style={{ flex: 1, padding: '12px 18px', borderRight: '1px solid var(--line-1)' }}>
|
<div style={{ flex: 1, padding: '12px 18px', borderRight: '1px solid var(--line-1)' }}>
|
||||||
@@ -62,7 +58,7 @@ export function MilestoneScreen({
|
|||||||
</p>
|
</p>
|
||||||
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>{name}</h1>
|
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>{name}</h1>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10 }}>
|
||||||
<Badge tone="ok" dot>{data ? `${data.doneCount}/${data.scopeCount} done` : 'ahead of forecast'}</Badge>
|
<Badge tone="ok" dot>{`${data.doneCount}/${data.scopeCount} done`}</Badge>
|
||||||
<span style={{ font: '400 12px var(--font-mono)', color: 'var(--ink-2)', whiteSpace: 'nowrap' }}>{forecastLabel}</span>
|
<span style={{ font: '400 12px var(--font-mono)', color: 'var(--ink-2)', whiteSpace: 'nowrap' }}>{forecastLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,20 +74,18 @@ export function MilestoneScreen({
|
|||||||
<Stat label="Forecast" value={forecastLabel} />
|
<Stat label="Forecast" value={forecastLabel} />
|
||||||
<div style={{ flex: 1, padding: '12px 18px' }}>
|
<div style={{ flex: 1, padding: '12px 18px' }}>
|
||||||
<div style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 5 }}>Remaining</div>
|
<div style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 5 }}>Remaining</div>
|
||||||
<div style={{ font: '500 14px var(--font-mono)', color: 'var(--ink-1)', whiteSpace: 'nowrap' }}>{data ? `${data.scopeCount - data.doneCount} open` : '−2d · cone narrowed'}</div>
|
<div style={{ font: '500 14px var(--font-mono)', color: 'var(--ink-1)', whiteSpace: 'nowrap' }}>{`${data.scopeCount - data.doneCount} open`}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: 14, alignItems: 'start' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: 14, alignItems: 'start' }}>
|
||||||
<Card overline="Burn-up" title={<>80% this lands <span style={{ whiteSpace: 'nowrap' }}>{data ? (data.forecastRange ?? 'shipped') : 'Mar 3–12'}</span></>} jade>
|
<Card overline="Burn-up" title={<>80% this lands <span style={{ whiteSpace: 'nowrap' }}>{data.forecastRange ?? 'shipped'}</span></>} jade>
|
||||||
<BurnUpCone data={data?.cone ?? undefined} />
|
<BurnUpCone data={data.cone ?? undefined} />
|
||||||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
|
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
|
||||||
{data
|
{data.cone
|
||||||
? data.cone
|
|
||||||
? `${data.scopeCount - data.doneCount} open of ${data.scopeCount}. Cone over what's left.`
|
? `${data.scopeCount - data.doneCount} open of ${data.scopeCount}. Cone over what's left.`
|
||||||
: 'Everything here has shipped.'
|
: 'Everything here has shipped.'}
|
||||||
: 'Comfortably ahead. Beta needs #87 more than it needs my commentary.'}
|
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,121 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
|
import type { BootstrapResult, DiscoverResult } from '../../global.js'
|
||||||
import logoIcon from '../../design/assets/logo-icon.png'
|
import logoIcon from '../../design/assets/logo-icon.png'
|
||||||
import { Badge, Button, Icon, Input, Radio, Tag } from '../ui/index.js'
|
import { Badge, Button, Icon, Input, Radio, Select, Tag } from '../ui/index.js'
|
||||||
|
|
||||||
// Onboarding / first connect — welcome → connect gitea → choose repo → bootstrap
|
/**
|
||||||
export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture') => void }) {
|
* First-run onboarding — a real, live wizard. Welcome → Connect (live token
|
||||||
const [step, setStep] = React.useState<number>(0)
|
* discovery) → Repo (pick from the repos that token can actually reach) →
|
||||||
const [conn, setConn] = React.useState<'idle' | 'testing' | 'ok'>('idle')
|
* Bootstrap (apply the label schema + create the pm-state sidecar, for real),
|
||||||
const [repo, setRepo] = React.useState('stephen/commitea')
|
* then into the app. Every step talks to the main-process bridge; nothing here
|
||||||
const [boot, setBoot] = React.useState<number>(-1) // -1 idle, 0..2 running, 3 done
|
* is faked. The token is saved encrypted in main only after a successful bootstrap.
|
||||||
|
*/
|
||||||
|
export function OnboardingScreen({ onConnected }: { onConnected: (dest: 'focus' | 'capture') => void }) {
|
||||||
|
const [step, setStep] = React.useState(0)
|
||||||
|
|
||||||
React.useEffect(() => {
|
// connect step
|
||||||
if (conn !== 'testing') return
|
const [baseUrl, setBaseUrl] = React.useState('https://gitea.stephenmann.io')
|
||||||
const t = setTimeout(() => setConn('ok'), 1100)
|
const [token, setToken] = React.useState('')
|
||||||
return () => clearTimeout(t)
|
const [discovery, setDiscovery] = React.useState<'idle' | 'testing' | 'ok'>('idle')
|
||||||
}, [conn])
|
const [found, setFound] = React.useState<{ user: string; owners: string[]; reposByOwner: Record<string, string[]> } | null>(null)
|
||||||
|
const [error, setError] = React.useState<string | null>(null)
|
||||||
|
|
||||||
React.useEffect(() => {
|
// repo step
|
||||||
if (boot < 0 || boot >= 3) return
|
const [owner, setOwner] = React.useState('')
|
||||||
const t = setTimeout(() => setBoot(boot + 1), 700)
|
const [repo, setRepo] = React.useState('')
|
||||||
return () => clearTimeout(t)
|
|
||||||
}, [boot])
|
// bootstrap step
|
||||||
|
const [boot, setBoot] = React.useState<'idle' | 'running' | 'done'>('idle')
|
||||||
|
const [bootResult, setBootResult] = React.useState<Extract<BootstrapResult, { ok: true }> | null>(null)
|
||||||
|
|
||||||
|
const repoCount = found ? found.owners.reduce((n, o) => n + (found.reposByOwner[o]?.length ?? 0), 0) : 0
|
||||||
|
|
||||||
|
const test = async () => {
|
||||||
|
if (!baseUrl.trim() || !token.trim() || discovery === 'testing') return
|
||||||
|
setError(null)
|
||||||
|
setDiscovery('testing')
|
||||||
|
const res: DiscoverResult = await window.commitea.config
|
||||||
|
.discover({ baseUrl: baseUrl.trim(), token: token.trim() })
|
||||||
|
.catch(() => ({ ok: false as const, error: 'unreachable' }))
|
||||||
|
if (!res.ok) {
|
||||||
|
setDiscovery('idle')
|
||||||
|
setFound(null)
|
||||||
|
setError(
|
||||||
|
res.error === '401' || res.error === '403'
|
||||||
|
? 'The token was rejected — check it has repo + issue scopes.'
|
||||||
|
: res.error === '404'
|
||||||
|
? "Couldn't reach that Gitea — check the URL."
|
||||||
|
: `Connection failed${res.error ? ` (${res.error})` : ''}.`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFound({ user: res.user, owners: res.owners, reposByOwner: res.reposByOwner })
|
||||||
|
setDiscovery('ok')
|
||||||
|
const o = res.owners.includes(res.user) ? res.user : res.owners[0] ?? ''
|
||||||
|
setOwner(o)
|
||||||
|
setRepo(res.reposByOwner[o]?.[0] ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const runBootstrap = async () => {
|
||||||
|
if (!found || boot === 'running') return
|
||||||
|
setError(null)
|
||||||
|
setBoot('running')
|
||||||
|
const res = await window.commitea.config
|
||||||
|
.bootstrap({ baseUrl: baseUrl.trim(), token: token.trim(), owner, repo, underOrg: owner !== found.user })
|
||||||
|
.catch(() => ({ ok: false as const, error: 'unreachable' }))
|
||||||
|
if (!res.ok) {
|
||||||
|
setBoot('idle')
|
||||||
|
setError(`Bootstrap failed${res.error ? ` (${res.error})` : ''}. Nothing was half-applied — you can retry.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// persist the connection now that the repo is prepared
|
||||||
|
await window.commitea.config.set({ baseUrl: baseUrl.trim(), owner, repo, token: token.trim() })
|
||||||
|
setBootResult(res)
|
||||||
|
setBoot('done')
|
||||||
|
}
|
||||||
|
|
||||||
const STEPS = ['Welcome', 'Connect', 'Repo', 'Bootstrap']
|
const STEPS = ['Welcome', 'Connect', 'Repo', 'Bootstrap']
|
||||||
const BOOT_TASKS = [
|
const repoOptions = found?.reposByOwner[owner] ?? []
|
||||||
'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 }) => (
|
const Frame = ({ children, footer }: { children: React.ReactNode; footer?: React.ReactNode }) => (
|
||||||
<div style={{
|
<div
|
||||||
background: 'var(--surface-card)', border: '1px solid var(--line-1)', borderRadius: 'var(--radius-3)',
|
style={{
|
||||||
boxShadow: 'var(--shadow-jade-line), var(--shadow-2)', padding: '30px 36px',
|
background: 'var(--surface-card)',
|
||||||
display: 'flex', flexDirection: 'column', gap: 16, width: '100%',
|
border: '1px solid var(--line-1)',
|
||||||
}}>
|
borderRadius: 'var(--radius-3)',
|
||||||
|
boxShadow: 'var(--shadow-jade-line), var(--shadow-2)',
|
||||||
|
padding: '30px 36px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 16,
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
{footer ? <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', borderTop: '1px solid var(--line-1)', paddingTop: 16 }}>{footer}</div> : null}
|
{error ? <p style={{ font: 'var(--text-agent)', color: 'var(--danger)', margin: 0 }}>{error}</p> : null}
|
||||||
|
{footer ? (
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', borderTop: '1px solid var(--line-1)', paddingTop: 16 }}>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
minHeight: '100vh', background: 'var(--surface-app)', display: 'flex', flexDirection: 'column',
|
style={{
|
||||||
alignItems: 'center', justifyContent: 'center', padding: 32, gap: 22,
|
minHeight: '100vh',
|
||||||
}} data-screen-label="onboarding">
|
background: 'var(--surface-app)',
|
||||||
{/* brand */}
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 32,
|
||||||
|
gap: 22,
|
||||||
|
}}
|
||||||
|
data-screen-label="onboarding"
|
||||||
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<img src={logoIcon} width="34" height="34" alt="" style={{ borderRadius: 8, display: 'block' }} />
|
<img src={logoIcon} width="34" height="34" alt="" style={{ borderRadius: 8, display: 'block' }} />
|
||||||
<span style={{ font: '400 27px/1 var(--font-serif-display)', color: 'var(--ink-1)' }}>
|
<span style={{ font: '400 27px/1 var(--font-serif-display)', color: 'var(--ink-1)' }}>
|
||||||
@@ -58,15 +128,24 @@ export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture
|
|||||||
{STEPS.map((s, i) => (
|
{STEPS.map((s, i) => (
|
||||||
<div key={s} style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
<div key={s} style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
|
||||||
<span style={{
|
<span
|
||||||
width: 20, height: 20, borderRadius: '50%', textAlign: 'center',
|
style={{
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: '50%',
|
||||||
|
textAlign: 'center',
|
||||||
font: '500 10.5px/20px var(--font-mono)',
|
font: '500 10.5px/20px var(--font-mono)',
|
||||||
background: i < step ? 'var(--accent)' : i === step ? 'var(--spruce-2)' : 'var(--paper-2)',
|
background: i < step ? 'var(--accent)' : i === step ? 'var(--spruce-2)' : 'var(--paper-2)',
|
||||||
color: i < step ? 'var(--ink-inverse)' : i === step ? 'var(--accent-text)' : 'var(--ink-3)',
|
color: i < step ? 'var(--ink-inverse)' : i === step ? 'var(--accent-text)' : 'var(--ink-3)',
|
||||||
}}>{i < step ? '✓' : i + 1}</span>
|
}}
|
||||||
<span style={{ font: `${i === step ? 600 : 400} 12px/1 var(--font-sans)`, color: i === step ? 'var(--ink-1)' : 'var(--ink-3)' }}>{s}</span>
|
>
|
||||||
|
{i < step ? '✓' : i + 1}
|
||||||
</span>
|
</span>
|
||||||
{i < STEPS.length - 1 ? <span style={{ width: 24, height: 1, background: 'var(--line-2)' }}></span> : null}
|
<span style={{ font: `${i === step ? 600 : 400} 12px/1 var(--font-sans)`, color: i === step ? 'var(--ink-1)' : 'var(--ink-3)' }}>
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{i < STEPS.length - 1 ? <span style={{ width: 24, height: 1, background: 'var(--line-2)' }} /> : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -76,8 +155,8 @@ export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture
|
|||||||
<Frame footer={<Button iconRight="arrow-right" onClick={() => setStep(1)}>Begin</Button>}>
|
<Frame footer={<Button iconRight="arrow-right" onClick={() => setStep(1)}>Begin</Button>}>
|
||||||
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Good morning.</h1>
|
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Good morning.</h1>
|
||||||
<p style={{ font: 'var(--text-agent-lg)', color: 'var(--ink-2)', margin: 0 }}>
|
<p style={{ font: 'var(--text-agent-lg)', color: 'var(--ink-2)', margin: 0 }}>
|
||||||
I'm Reginald, your project manager. I interview you instead of making you fill in forms,
|
I'm Reginald, your project manager. I interview you instead of making you fill in forms, I forecast in
|
||||||
I forecast in honest ranges, and I never do the arithmetic myself — there's a scheduler for that.
|
honest ranges, and I never do the arithmetic myself — there's a scheduler for that.
|
||||||
</p>
|
</p>
|
||||||
<p style={{ font: 'var(--text-body)', color: 'var(--ink-2)', margin: 0 }}>
|
<p style={{ font: 'var(--text-body)', color: 'var(--ink-2)', margin: 0 }}>
|
||||||
Your plans live in your own Gitea as ordinary issues and labels. Delete me and nothing human is lost.
|
Your plans live in your own Gitea as ordinary issues and labels. Delete me and nothing human is lost.
|
||||||
@@ -86,79 +165,196 @@ export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === 1 ? (
|
{step === 1 ? (
|
||||||
<Frame footer={<>
|
<Frame
|
||||||
<Button variant="ghost" onClick={() => setStep(0)}>Back</Button>
|
footer={
|
||||||
<Button iconRight="arrow-right" disabled={conn !== 'ok'} onClick={() => setStep(2)}>Continue</Button>
|
<>
|
||||||
</>}>
|
<Button variant="ghost" onClick={() => setStep(0)}>
|
||||||
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Your Gitea</h1>
|
Back
|
||||||
<Input label="Base URL" icon="link" mono defaultValue="https://gitea.stephenmann.io" />
|
|
||||||
<Input label="Access token" icon="keyboard" mono type="password" defaultValue="ct_9f2e81c4a7d6" hint="Scopes: repo, issue. Nothing more." />
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
||||||
<Button variant="secondary" icon={conn === 'testing' ? 'loader-circle' : 'zap'} disabled={conn === 'testing'} onClick={() => setConn('testing')}>
|
|
||||||
{conn === 'testing' ? 'Ringing the bell…' : 'Test connection'}
|
|
||||||
</Button>
|
</Button>
|
||||||
{conn === 'ok' ? <Badge tone="ok" dot>connected · 3 repos visible</Badge> : null}
|
<Button iconRight="arrow-right" disabled={discovery !== 'ok'} onClick={() => setStep(2)}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Your Gitea</h1>
|
||||||
|
<Input
|
||||||
|
label="Base URL"
|
||||||
|
icon="link"
|
||||||
|
mono
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
setBaseUrl(e.target.value)
|
||||||
|
setDiscovery('idle')
|
||||||
|
setFound(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Access token"
|
||||||
|
icon="keyboard"
|
||||||
|
mono
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
placeholder="gitea PAT"
|
||||||
|
hint="Scopes: repo, issue. Nothing more."
|
||||||
|
onChange={(e) => {
|
||||||
|
setToken(e.target.value)
|
||||||
|
setDiscovery('idle')
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
void test()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
icon={discovery === 'testing' ? 'loader-circle' : 'zap'}
|
||||||
|
disabled={discovery === 'testing' || !baseUrl.trim() || !token.trim()}
|
||||||
|
onClick={() => void test()}
|
||||||
|
>
|
||||||
|
{discovery === 'testing' ? 'Ringing the bell…' : 'Test connection'}
|
||||||
|
</Button>
|
||||||
|
{discovery === 'ok' ? (
|
||||||
|
<Badge tone="ok" dot>
|
||||||
|
connected · {repoCount} repo{repoCount === 1 ? '' : 's'} visible
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Frame>
|
</Frame>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === 2 ? (
|
{step === 2 ? (
|
||||||
<Frame footer={<>
|
<Frame
|
||||||
<Button variant="ghost" onClick={() => setStep(1)}>Back</Button>
|
footer={
|
||||||
<Button iconRight="arrow-right" onClick={() => setStep(3)}>Continue</Button>
|
<>
|
||||||
</>}>
|
<Button variant="ghost" onClick={() => setStep(1)}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button iconRight="arrow-right" disabled={!owner || !repo} onClick={() => setStep(3)}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Which repo shall I manage?</h1>
|
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>Which repo shall I manage?</h1>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
{found && found.owners.length > 1 ? (
|
||||||
{['stephen/commitea', 'stephen/novelpad', 'stephen/infra'].map((r: string) => (
|
<Select
|
||||||
<label key={r} style={{
|
label="Owner"
|
||||||
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', cursor: 'pointer',
|
value={owner}
|
||||||
background: repo === r ? 'var(--spruce-1)' : 'var(--paper-0)',
|
options={found.owners.map((o) => ({ value: o, label: o }))}
|
||||||
border: `1px solid ${repo === r ? 'var(--spruce-3)' : 'var(--line-1)'}`,
|
onChange={(e) => {
|
||||||
|
const o = e.target.value
|
||||||
|
setOwner(o)
|
||||||
|
setRepo(found.reposByOwner[o]?.[0] ?? '')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: 260, overflowY: 'auto' }}>
|
||||||
|
{repoOptions.map((r) => {
|
||||||
|
const full = `${owner}/${r}`
|
||||||
|
const selected = repo === r
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={r}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: '10px 14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: selected ? 'var(--spruce-1)' : 'var(--paper-0)',
|
||||||
|
border: `1px solid ${selected ? 'var(--spruce-3)' : 'var(--line-1)'}`,
|
||||||
borderRadius: 'var(--radius-2)',
|
borderRadius: 'var(--radius-2)',
|
||||||
}}>
|
}}
|
||||||
<Radio name="repo" checked={repo === r} onChange={() => setRepo(r)} />
|
>
|
||||||
|
<Radio name="repo" checked={selected} onChange={() => setRepo(r)} />
|
||||||
<Icon name="git-branch" size={14} style={{ color: 'var(--ink-3)' }} />
|
<Icon name="git-branch" size={14} style={{ color: 'var(--ink-3)' }} />
|
||||||
<span style={{ font: 'var(--text-data)', color: 'var(--ink-1)' }}>{r}</span>
|
<span style={{ font: 'var(--text-data)', color: 'var(--ink-1)' }}>{full}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>One to start. You can add more later in Settings.</p>
|
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>
|
||||||
|
One to start. You can switch later in Settings.
|
||||||
|
</p>
|
||||||
</Frame>
|
</Frame>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === 3 ? (
|
{step === 3 ? (
|
||||||
<Frame footer={boot === 3 ? <>
|
<Frame
|
||||||
<Button variant="ghost" onClick={() => onDone('focus')}>Just look around</Button>
|
footer={
|
||||||
<Button icon="sparkles" onClick={() => onDone('capture')}>Start a capture</Button>
|
boot === 'done' ? (
|
||||||
</> : <>
|
<>
|
||||||
<Button variant="ghost" onClick={() => setStep(2)} disabled={boot >= 0}>Back</Button>
|
<Button variant="ghost" onClick={() => onConnected('focus')}>
|
||||||
<Button disabled={boot >= 0} onClick={() => setBoot(0)}>Make it so</Button>
|
Just look around
|
||||||
</>}>
|
</Button>
|
||||||
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>
|
<Button icon="sparkles" onClick={() => onConnected('capture')}>
|
||||||
{boot === 3 ? 'All set.' : 'With your approval'}
|
Start a capture
|
||||||
</h1>
|
</Button>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
</>
|
||||||
{BOOT_TASKS.map((t: string, i: number) => (
|
|
||||||
<div key={t} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<span style={{ display: 'inline-flex', color: boot > i ? 'var(--ok)' : boot === i ? 'var(--warn)' : 'var(--ink-3)' }}>
|
|
||||||
<Icon name={boot > i ? 'circle-check' : boot === i ? 'loader-circle' : 'circle-dashed'} size={15} />
|
|
||||||
</span>
|
|
||||||
<span style={{ font: 'var(--text-body)', color: boot > i ? 'var(--ink-1)' : 'var(--ink-2)', whiteSpace: 'nowrap' }}>{t}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, paddingLeft: 25 }}>
|
|
||||||
{['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d', 'p/1', 'p/2', 'p/3', 'p/4', 'deadline/hard'].map((l: string) => <Tag key={l} label={l} />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{boot === 3 ? (
|
|
||||||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
|
|
||||||
The pot is empty. Tell me what you're planning and I'll draw up the tickets.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setStep(2)} disabled={boot === 'running'}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button icon={boot === 'running' ? 'loader-circle' : undefined} disabled={boot === 'running'} onClick={() => void runBootstrap()}>
|
||||||
|
{boot === 'running' ? 'Preparing…' : 'Make it so'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h1 style={{ font: 'var(--text-title)', color: 'var(--ink-1)', margin: 0 }}>
|
||||||
|
{boot === 'done' ? 'All set.' : 'With your approval'}
|
||||||
|
</h1>
|
||||||
|
<p style={{ font: 'var(--text-body)', color: 'var(--ink-2)', margin: 0 }}>
|
||||||
|
I'll prepare <span style={{ font: 'var(--text-data)', color: 'var(--ink-1)' }}>{owner}/{repo}</span>: apply
|
||||||
|
the label schema, and create the pm-state sidecar for machine-derived state.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<BootTask
|
||||||
|
state={boot}
|
||||||
|
label={
|
||||||
|
bootResult
|
||||||
|
? bootResult.labels.created.length
|
||||||
|
? `Applied the label schema (${bootResult.labels.created.length} created, ${bootResult.labels.existing.length} already there)`
|
||||||
|
: `Label schema already applied (${bootResult.labels.existing.length} labels)`
|
||||||
|
: `Apply the label schema to ${owner}/${repo}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<BootTask
|
||||||
|
state={boot}
|
||||||
|
label={
|
||||||
|
bootResult
|
||||||
|
? bootResult.pmState === 'created'
|
||||||
|
? `Created ${owner}/${bootResult.pmStateRepo} (the sidecar)`
|
||||||
|
: `${owner}/${bootResult.pmStateRepo} already exists`
|
||||||
|
: `Create ${owner}/${repo}-pm-state (the sidecar)`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, paddingLeft: 25 }}>
|
||||||
|
{(bootResult ? [...bootResult.labels.created, ...bootResult.labels.existing] : [
|
||||||
|
'est/1d',
|
||||||
|
'est/2d',
|
||||||
|
'est/3d',
|
||||||
|
'est/5d',
|
||||||
|
'est/8d',
|
||||||
|
'p/1',
|
||||||
|
'p/2',
|
||||||
|
'p/3',
|
||||||
|
'p/4',
|
||||||
|
'deadline/hard',
|
||||||
|
]).map((l) => (
|
||||||
|
<Tag key={l} label={l} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>
|
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: 0 }}>
|
||||||
No bot comments, no body frontmatter, no synthetic issues — ever. Labels are the only footprint.
|
No bot comments, no body frontmatter, no synthetic issues — ever. Labels are the only footprint.
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
</Frame>
|
</Frame>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -167,3 +363,16 @@ export function OnboardingScreen({ onDone }: { onDone: (dest: 'focus' | 'capture
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BootTask({ state, label }: { state: 'idle' | 'running' | 'done'; label: string }) {
|
||||||
|
const icon = state === 'done' ? 'circle-check' : state === 'running' ? 'loader-circle' : 'circle-dashed'
|
||||||
|
const color = state === 'done' ? 'var(--ok)' : state === 'running' ? 'var(--warn)' : 'var(--ink-3)'
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<span style={{ display: 'inline-flex', color }}>
|
||||||
|
<Icon name={icon} size={15} />
|
||||||
|
</span>
|
||||||
|
<span style={{ font: 'var(--text-body)', color: state === 'done' ? 'var(--ink-1)' : 'var(--ink-2)' }}>{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { type CapacityMember, capacityPerWorkday } from '@commitea/core'
|
|||||||
|
|
||||||
import { RunwayBar } from '../charts/chart.js'
|
import { RunwayBar } from '../charts/chart.js'
|
||||||
import { Card, Badge, Tag, Icon, IconButton } from '../ui/index.js'
|
import { Card, Badge, Tag, Icon, IconButton } from '../ui/index.js'
|
||||||
import { RUNWAY, CAPACITY, type RunwayMilestone } from '../../data/fixtures.js'
|
import { EmptyState } from '../shell/states.js'
|
||||||
|
import { type RunwayMilestone } from '../../data/view-types.js'
|
||||||
|
|
||||||
// Runway — capacity vs milestone dates; ranges, never points.
|
// Runway — capacity vs milestone dates; ranges, never points.
|
||||||
// `milestones` (real per-milestone forecasts) + `capacity` (real config) override the demo.
|
// `milestones` (real per-milestone forecasts) + `capacity` (real config) override the demo.
|
||||||
@@ -21,7 +22,16 @@ export function RunwayScreen({
|
|||||||
milestones?: RunwayMilestone[]
|
milestones?: RunwayMilestone[]
|
||||||
capacity?: CapacityMember[]
|
capacity?: CapacityMember[]
|
||||||
}) {
|
}) {
|
||||||
const rows = milestones && milestones.length ? milestones : RUNWAY
|
if (!milestones || !milestones.length) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="chart-line"
|
||||||
|
title="No runway yet"
|
||||||
|
line="Add milestones and estimates and I'll project the finish."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rows = milestones
|
||||||
const capacityRows =
|
const capacityRows =
|
||||||
capacity && capacity.length
|
capacity && capacity.length
|
||||||
? capacity.map((m) => ({
|
? capacity.map((m) => ({
|
||||||
@@ -29,19 +39,19 @@ export function RunwayScreen({
|
|||||||
slices: `focus ${m.focusFactor} · alloc ${Math.round(m.allocation * 100)}%`,
|
slices: `focus ${m.focusFactor} · alloc ${Math.round(m.allocation * 100)}%`,
|
||||||
hours: `${capacityPerWorkday(m).toFixed(2)} pd/day`,
|
hours: `${capacityPerWorkday(m).toFixed(2)} pd/day`,
|
||||||
}))
|
}))
|
||||||
: CAPACITY
|
: []
|
||||||
const excluded = calibration?.excludedSameDay ?? 0
|
const excluded = calibration?.excludedSameDay ?? 0
|
||||||
const calibNote = calibration
|
const calibNote = calibration
|
||||||
? calibration.coldStart
|
? calibration.coldStart
|
||||||
? `cold-start priors · ${calibration.n}/20 closed issues estimated` +
|
? `cold-start priors · ${calibration.n}/20 closed issues estimated` +
|
||||||
(excluded > 0 ? ` · ${excluded} same-day close${excluded === 1 ? '' : 's'} can’t calibrate` : '')
|
(excluded > 0 ? ` · ${excluded} same-day close${excluded === 1 ? '' : 's'} can’t calibrate` : '')
|
||||||
: `calibrated on ${calibration.n} closed ${calibration.n === 1 ? 'issue' : 'issues'}`
|
: `calibrated on ${calibration.n} closed ${calibration.n === 1 ? 'issue' : 'issues'}`
|
||||||
: 'calibrated on 27 closed issues'
|
: null
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
|
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
|
||||||
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Runway</h1>
|
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Runway</h1>
|
||||||
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>capacity vs milestone dates · {calibNote}</p>
|
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>capacity vs milestone dates{calibNote ? ` · ${calibNote}` : ''}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Card overline="Milestones" flush>
|
<Card overline="Milestones" flush>
|
||||||
@@ -91,14 +101,7 @@ export function RunwayScreen({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card overline="Calibration" actions={<IconButton icon="arrow-up-right" label="Full report" size="sm" onClick={onOpenCalibration} />}>
|
<Card overline="Calibration" actions={<IconButton icon="arrow-up-right" label="Full report" size="sm" onClick={onOpenCalibration} />} />
|
||||||
<p style={{ font: 'var(--text-body)', margin: '0 0 8px' }}>
|
|
||||||
Your estimates run <strong>18% optimistic</strong> on <code>est/3d</code> and above. Smaller tickets are honest.
|
|
||||||
</p>
|
|
||||||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
|
|
||||||
I widen the cone accordingly. No judgement — it's the most common shape of hope.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import { type IssueRef, STANDUP, type StandupData } from '../../data/fixtures.js'
|
import { type IssueRef, type StandupData } from '../../data/view-types.js'
|
||||||
|
import { EmptyState } from '../shell/states.js'
|
||||||
import { Badge, Button, Icon } from '../ui/index.js'
|
import { Badge, Button, Icon } from '../ui/index.js'
|
||||||
|
|
||||||
const standupCSS = `
|
const standupCSS = `
|
||||||
@@ -42,12 +43,6 @@ export function StandupScreen({
|
|||||||
onOpenIssue: (issue: IssueRef) => void
|
onOpenIssue: (issue: IssueRef) => void
|
||||||
data?: StandupData
|
data?: StandupData
|
||||||
}) {
|
}) {
|
||||||
const s = data ?? STANDUP
|
|
||||||
const NAG_REF: IssueRef = {
|
|
||||||
id: s.nag.id,
|
|
||||||
days: s.nag.days,
|
|
||||||
}
|
|
||||||
|
|
||||||
const Section = ({ overline, order, children }: { overline: string; order: number; children: React.ReactNode }) => (
|
const Section = ({ overline, order, children }: { overline: string; order: number; children: React.ReactNode }) => (
|
||||||
<section
|
<section
|
||||||
className="ct-standup-section"
|
className="ct-standup-section"
|
||||||
@@ -70,6 +65,22 @@ export function StandupScreen({
|
|||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="sun"
|
||||||
|
title="Nothing to report"
|
||||||
|
line="No overnight drift — I'll have a plan when there's work to pour."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = data
|
||||||
|
const NAG_REF: IssueRef = {
|
||||||
|
id: s.nag.id,
|
||||||
|
days: s.nag.days,
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 660, margin: '0 auto' }}>
|
<div style={{ maxWidth: 660, margin: '0 auto' }}>
|
||||||
<article
|
<article
|
||||||
@@ -85,7 +96,7 @@ export function StandupScreen({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<header className="ct-standup-section" style={{ borderBottom: 'var(--rule-double)', paddingBottom: 16 }}>
|
<header className="ct-standup-section" style={{ borderBottom: 'var(--rule-double)', paddingBottom: 16 }}>
|
||||||
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '0 0 8px' }}>{s.date} · prepared 07:00</p>
|
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '0 0 8px' }}>{s.date}</p>
|
||||||
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Morning standup</h1>
|
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Morning standup</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import logoIcon from '../../design/assets/logo-icon.png'
|
|||||||
import type { IssueChange } from '@commitea/core'
|
import type { IssueChange } from '@commitea/core'
|
||||||
|
|
||||||
import type { PublicConfig } from '../../global.js'
|
import type { PublicConfig } from '../../global.js'
|
||||||
import type { IssueRef } from '../../data/fixtures.js'
|
import type { IssueRef } from '../../data/view-types.js'
|
||||||
import { ConnectScreen } from '../screens/connect-screen.js'
|
import { ConnectScreen } from '../screens/connect-screen.js'
|
||||||
import {
|
import {
|
||||||
backlogCalibration,
|
backlogCalibration,
|
||||||
@@ -23,7 +23,6 @@ import { inboxView } from '../../lib/views/inbox-view.js'
|
|||||||
import { issueDetailView } from '../../lib/views/issue-detail.js'
|
import { issueDetailView } from '../../lib/views/issue-detail.js'
|
||||||
import type { ProjectData } from '../../lib/views/project-data.js'
|
import type { ProjectData } from '../../lib/views/project-data.js'
|
||||||
import { standupView } from '../../lib/views/standup-view.js'
|
import { standupView } from '../../lib/views/standup-view.js'
|
||||||
import { PrimitivesGallery } from '../gallery.js'
|
|
||||||
import { BoardScreen } from '../screens/board-screen.js'
|
import { BoardScreen } from '../screens/board-screen.js'
|
||||||
import { CalibrationScreen } from '../screens/calibration-screen.js'
|
import { CalibrationScreen } from '../screens/calibration-screen.js'
|
||||||
import { CaptureScreen } from '../screens/capture-screen.js'
|
import { CaptureScreen } from '../screens/capture-screen.js'
|
||||||
@@ -38,8 +37,7 @@ import { SettingsScreen } from '../screens/settings-screen.js'
|
|||||||
import { StandupScreen } from '../screens/standup-screen.js'
|
import { StandupScreen } from '../screens/standup-screen.js'
|
||||||
import { Icon, Switch } from '../ui/index.js'
|
import { Icon, Switch } from '../ui/index.js'
|
||||||
import { ChatPanel } from './chat-panel.js'
|
import { ChatPanel } from './chat-panel.js'
|
||||||
import { PlaceholderScreen } from './placeholder-screen.js'
|
import { OfflineBanner } from './states.js'
|
||||||
import { OfflineBanner, StatesScreen } from './states.js'
|
|
||||||
|
|
||||||
type View =
|
type View =
|
||||||
| 'standup'
|
| 'standup'
|
||||||
@@ -50,9 +48,6 @@ type View =
|
|||||||
| 'runway'
|
| 'runway'
|
||||||
| 'directives'
|
| 'directives'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
| 'states'
|
|
||||||
| 'firstrun'
|
|
||||||
| 'primitives'
|
|
||||||
| 'issue'
|
| 'issue'
|
||||||
| 'calibration'
|
| 'calibration'
|
||||||
| 'milestone'
|
| 'milestone'
|
||||||
@@ -64,52 +59,21 @@ interface NavEntry {
|
|||||||
count?: number | null
|
count?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// which phase builds each not-yet-real view (shown on its placeholder)
|
|
||||||
const PHASE: Partial<Record<View, string>> = {
|
|
||||||
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<Record<View, string>> = {
|
|
||||||
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() {
|
export function AppShell() {
|
||||||
const [view, setView] = useState<View>('focus')
|
const [view, setView] = useState<View>('focus')
|
||||||
const [prevView, setPrevView] = useState<View>('focus')
|
const [prevView, setPrevView] = useState<View>('focus')
|
||||||
const [dark, setDark] = useState(false)
|
const [dark, setDark] = useState(false)
|
||||||
const [offline, setOffline] = useState(false)
|
|
||||||
const [issue, setIssue] = useState<IssueRef | null>(null)
|
const [issue, setIssue] = useState<IssueRef | null>(null)
|
||||||
const [readIds, setReadIds] = useState<number[]>([])
|
const [readIds, setReadIds] = useState<number[]>([])
|
||||||
const [milestoneId, setMilestoneId] = useState<number | null>(null)
|
const [milestoneId, setMilestoneId] = useState<number | null>(null)
|
||||||
const [gate, setGate] = useState<'checking' | 'connect' | 'ready'>('checking')
|
const [gate, setGate] = useState<'checking' | 'connect' | 'ready'>('checking')
|
||||||
const [demo, setDemo] = useState(false)
|
|
||||||
const [pubConfig, setPubConfig] = useState<PublicConfig | null>(null)
|
const [pubConfig, setPubConfig] = useState<PublicConfig | null>(null)
|
||||||
const [collaborators, setCollaborators] = useState<{ login: string; name: string }[]>([])
|
const [collaborators, setCollaborators] = useState<{ login: string; name: string }[]>([])
|
||||||
const [backlog, refetchBacklog] = useBacklog()
|
const [backlog, refetchBacklog] = useBacklog()
|
||||||
|
// Real connectivity: the reconcile serves the stale cache (or errors with no
|
||||||
|
// cache) only when gitea is unreachable. No manual toggle — this is the truth.
|
||||||
|
const offline = backlog.status === 'error' || (backlog.status === 'ready' && backlog.stale)
|
||||||
|
const staleSince = backlog.status === 'ready' ? backlog.savedAt : undefined
|
||||||
const capacityMembers = useCapacity()
|
const capacityMembers = useCapacity()
|
||||||
const workers = capacityWorkers(capacityMembers)
|
const workers = capacityWorkers(capacityMembers)
|
||||||
const boardColumns =
|
const boardColumns =
|
||||||
@@ -161,10 +125,10 @@ export function AppShell() {
|
|||||||
const gantt = projectData ? ganttView(projectData) : undefined
|
const gantt = projectData ? ganttView(projectData) : undefined
|
||||||
const depsGraph = projectData ? depsGraphView(projectData) : undefined
|
const depsGraph = projectData ? depsGraphView(projectData) : undefined
|
||||||
// The rail's unread badge tracks the real inbox once reconciled (still-unread
|
// The rail's unread badge tracks the real inbox once reconciled (still-unread
|
||||||
// minus what's been opened); demo shows the fixture count.
|
// minus what's been opened); null until the first reconcile lands.
|
||||||
const inboxUnread = inbox
|
const inboxUnread = inbox
|
||||||
? inbox.filter((n) => n.unread && !readIds.includes(n.id)).length
|
? inbox.filter((n) => n.unread && !readIds.includes(n.id)).length
|
||||||
: INBOX_UNREAD
|
: null
|
||||||
|
|
||||||
// The real connected host (from the saved config), for the rail's status line.
|
// The real connected host (from the saved config), for the rail's status line.
|
||||||
const hostLabel = (() => {
|
const hostLabel = (() => {
|
||||||
@@ -179,13 +143,12 @@ export function AppShell() {
|
|||||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
|
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
|
||||||
}, [dark])
|
}, [dark])
|
||||||
|
|
||||||
// Gate: e2e/demo → the shell (fixtures); configured → the shell (real data);
|
// Gate: demo (e2e serves the demo snapshot) or configured → the shell;
|
||||||
// otherwise → the connect screen (each teammate brings their own token).
|
// otherwise → the connect screen (each teammate brings their own token).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.commitea.gitea
|
window.commitea.gitea
|
||||||
.status()
|
.status()
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
setDemo(s.demo)
|
|
||||||
setGate(s.demo || s.configured ? 'ready' : 'connect')
|
setGate(s.demo || s.configured ? 'ready' : 'connect')
|
||||||
})
|
})
|
||||||
.catch(() => setGate('connect'))
|
.catch(() => setGate('connect'))
|
||||||
@@ -205,7 +168,7 @@ export function AppShell() {
|
|||||||
if (view !== 'issue') setPrevView(view)
|
if (view !== 'issue') setPrevView(view)
|
||||||
// Callers into the sidecar (e.g. a "blocks #N" chip) may know only the id.
|
// 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
|
// Backfill the real title + labels from the reconciled backlog so the header
|
||||||
// isn't blank; fall back to whatever the caller passed (demo fixtures).
|
// isn't blank; fall back to whatever the caller passed.
|
||||||
let full = ref
|
let full = ref
|
||||||
if (backlog.status === 'ready' && (!ref.title || !ref.labels)) {
|
if (backlog.status === 'ready' && (!ref.title || !ref.labels)) {
|
||||||
const found = backlog.issues.find((i) => i.number === ref.id)
|
const found = backlog.issues.find((i) => i.number === ref.id)
|
||||||
@@ -367,12 +330,8 @@ export function AppShell() {
|
|||||||
currentMilestoneId={currentIssue?.milestone?.id ?? null}
|
currentMilestoneId={currentIssue?.milestone?.id ?? null}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
case 'states':
|
|
||||||
return <StatesScreen onCapture={() => setView('capture')} />
|
|
||||||
case 'primitives':
|
|
||||||
return <PrimitivesGallery />
|
|
||||||
default:
|
default:
|
||||||
return <PlaceholderScreen title={TITLE[view] ?? view} phase={PHASE[view] ?? 'a later phase'} />
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,21 +340,19 @@ export function AppShell() {
|
|||||||
return <div style={{ height: '100vh', background: 'var(--surface-app)' }} />
|
return <div style={{ height: '100vh', background: 'var(--surface-app)' }} />
|
||||||
}
|
}
|
||||||
if (gate === 'connect') {
|
if (gate === 'connect') {
|
||||||
return (
|
const onConnected = (dest?: 'focus' | 'capture') => {
|
||||||
<ConnectScreen
|
|
||||||
existing={pubConfig}
|
|
||||||
onConnected={() => {
|
|
||||||
window.commitea.config.get().then(setPubConfig).catch(() => {})
|
window.commitea.config.get().then(setPubConfig).catch(() => {})
|
||||||
|
if (dest) setView(dest)
|
||||||
setGate('ready')
|
setGate('ready')
|
||||||
refetchBacklog()
|
refetchBacklog()
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
// First run (no saved config) → the full live wizard; reconnecting/editing an
|
||||||
// First run is full-window — no rail, no chat panel
|
// existing connection → the compact card.
|
||||||
if (view === 'firstrun') {
|
return pubConfig ? (
|
||||||
return <OnboardingScreen onDone={(dest) => setView(dest)} />
|
<ConnectScreen existing={pubConfig} onConnected={() => onConnected()} />
|
||||||
|
) : (
|
||||||
|
<OnboardingScreen onConnected={onConnected} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -435,33 +392,25 @@ export function AppShell() {
|
|||||||
|
|
||||||
<div style={{ borderTop: '1px solid var(--line-1)', margin: '10px 8px' }} />
|
<div style={{ borderTop: '1px solid var(--line-1)', margin: '10px 8px' }} />
|
||||||
<NavItem item={{ id: 'settings', label: 'Settings', icon: 'settings-2' }} />
|
<NavItem item={{ id: 'settings', label: 'Settings', icon: 'settings-2' }} />
|
||||||
{/* 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 ? (
|
|
||||||
<>
|
|
||||||
<NavItem item={{ id: 'firstrun', label: 'First run', icon: 'play' }} />
|
|
||||||
<NavItem item={{ id: 'states', label: 'States', icon: 'circle-dashed' }} />
|
|
||||||
<NavItem item={{ id: 'primitives', label: 'Primitives', icon: 'layers' }} />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div style={{ marginTop: 'auto', padding: '0 12px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ marginTop: 'auto', padding: '0 12px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
<button
|
<div
|
||||||
type="button"
|
title={
|
||||||
onClick={() => setOffline((o) => !o)}
|
backlog.status === 'loading'
|
||||||
title="Toggle the connection (demo)"
|
? `Connecting to ${hostLabel}…`
|
||||||
aria-label={offline ? 'Connection: offline' : 'Connection: online'}
|
: offline
|
||||||
|
? `Offline — reading from cache${staleSince ? ` (since ${new Date(staleSince).toLocaleString()})` : ''}`
|
||||||
|
: `Connected to ${hostLabel}`
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
backlog.status === 'loading' ? 'Connection: connecting' : offline ? 'Connection: offline' : 'Connection: online'
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
font: '400 11px var(--font-mono)',
|
font: '400 11px var(--font-mono)',
|
||||||
color: 'var(--ink-3)',
|
color: 'var(--ink-3)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
gap: 6,
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
padding: 0,
|
|
||||||
cursor: 'pointer',
|
|
||||||
textAlign: 'left',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -469,12 +418,13 @@ export function AppShell() {
|
|||||||
width: 6,
|
width: 6,
|
||||||
height: 6,
|
height: 6,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: offline ? 'var(--danger)' : 'var(--ok)',
|
background:
|
||||||
|
backlog.status === 'loading' ? 'var(--warn)' : offline ? 'var(--danger)' : 'var(--ok)',
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{hostLabel}
|
{hostLabel}
|
||||||
</button>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
label={<span style={{ font: 'var(--text-caption)' }}>Evening service</span>}
|
label={<span style={{ font: 'var(--text-caption)' }}>Evening service</span>}
|
||||||
checked={dark}
|
checked={dark}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { Button } from '../ui/index.js'
|
|
||||||
import { EmptyState } from './states.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stand-in for views not yet built (Focus, Board, Runway, …). Keeps navigation
|
|
||||||
* live so the shell is demonstrable before the real screens land in P3-3+.
|
|
||||||
* `onOpenIssue`, when given, exercises the shell's issue drill-in + back-stack;
|
|
||||||
* real screens replace this with actual issue rows in P3-3+.
|
|
||||||
*/
|
|
||||||
export function PlaceholderScreen({
|
|
||||||
title,
|
|
||||||
phase,
|
|
||||||
onOpenIssue,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
phase: string
|
|
||||||
onOpenIssue?: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
||||||
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
|
|
||||||
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>{title}</h1>
|
|
||||||
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>
|
|
||||||
arrives in {phase} · the shell and navigation are live
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<EmptyState
|
|
||||||
icon="coffee"
|
|
||||||
title="Not yet poured"
|
|
||||||
line="This view is on the boil for a later phase. The rail, routing, theme, and Reginald’s panel are already working around it."
|
|
||||||
/>
|
|
||||||
{onOpenIssue ? (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
|
||||||
<Button variant="ghost" iconRight="arrow-right" onClick={onOpenIssue}>
|
|
||||||
Preview an issue page
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import { Badge, Button, Icon } from '../ui/index.js'
|
import { Button, Icon } from '../ui/index.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared empty/trouble states + the States gallery screen, ported from the
|
* Shared empty / trouble states, reused across real screens: EmptyState for
|
||||||
* handoff. EmptyState/OfflineBanner/ModelAwayState are reused across real
|
* "nothing here yet", OfflineBanner when a reconcile is serving the stale cache,
|
||||||
* screens as they land; StatesScreen is the specimen gallery.
|
* ModelAwayState when Reginald's model endpoint is unconfigured/unreachable.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface EmptyStateProps {
|
export interface EmptyStateProps {
|
||||||
@@ -93,9 +93,6 @@ export function ModelAwayState() {
|
|||||||
</span>
|
</span>
|
||||||
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-2)' }}>Reginald</span>
|
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-2)' }}>Reginald</span>
|
||||||
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>model offline</span>
|
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>model offline</span>
|
||||||
<span style={{ marginLeft: 'auto' }}>
|
|
||||||
<Badge>queued: 1 directive</Badge>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
|
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
|
||||||
The model is away from its desk. Reads still work; writes will wait their turn.
|
The model is away from its desk. Reads still work; writes will wait their turn.
|
||||||
@@ -103,130 +100,3 @@ export function ModelAwayState() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Specimen({ label, children }: { label: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>{label}</span>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
border: '1px dashed var(--line-2)',
|
|
||||||
borderRadius: 'var(--radius-3)',
|
|
||||||
background: 'var(--surface-card)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatesScreen({ onCapture }: { onCapture?: () => void }) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
||||||
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
|
|
||||||
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>States</h1>
|
|
||||||
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>
|
|
||||||
empty & trouble · specimens as wired in the app
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
font: 'var(--text-overline)',
|
|
||||||
letterSpacing: 'var(--letter-spacing-wide)',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
color: 'var(--ink-3)',
|
|
||||||
margin: '4px 0 0',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Empty
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
|
|
||||||
<Specimen label="the pot · no issues">
|
|
||||||
<EmptyState
|
|
||||||
compact
|
|
||||||
icon="inbox"
|
|
||||||
title="The pot is empty"
|
|
||||||
line="Tell me what you’re planning and I’ll draw up the tickets."
|
|
||||||
action="Start a capture"
|
|
||||||
onAction={onCapture}
|
|
||||||
/>
|
|
||||||
</Specimen>
|
|
||||||
<Specimen label="morning service · nothing scheduled">
|
|
||||||
<EmptyState
|
|
||||||
compact
|
|
||||||
icon="coffee"
|
|
||||||
title="Nothing to pour"
|
|
||||||
line="Capture some work, or enjoy the silence — it never lasts."
|
|
||||||
/>
|
|
||||||
</Specimen>
|
|
||||||
<Specimen label="directives · no entries">
|
|
||||||
<EmptyState
|
|
||||||
compact
|
|
||||||
icon="flag"
|
|
||||||
title="No directives yet"
|
|
||||||
line="When you overrule the scheduler, it goes on the record here — who, when, what, why."
|
|
||||||
/>
|
|
||||||
</Specimen>
|
|
||||||
<Specimen label="board search · no match">
|
|
||||||
<EmptyState
|
|
||||||
compact
|
|
||||||
icon="search"
|
|
||||||
title="Nothing by that name"
|
|
||||||
line="The pot holds 24 issues; none of them answer to that."
|
|
||||||
/>
|
|
||||||
</Specimen>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
font: 'var(--text-overline)',
|
|
||||||
letterSpacing: 'var(--letter-spacing-wide)',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
color: 'var(--ink-3)',
|
|
||||||
margin: '8px 0 0',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Trouble
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
||||||
<Specimen label="gitea unreachable · banner above main content (toggle the rail’s connection dot to see it live)">
|
|
||||||
<div style={{ padding: 12 }}>
|
|
||||||
<OfflineBanner />
|
|
||||||
</div>
|
|
||||||
</Specimen>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
|
|
||||||
<Specimen label="reginald’s panel · model offline">
|
|
||||||
<ModelAwayState />
|
|
||||||
</Specimen>
|
|
||||||
<Specimen label="webhooks down · poll fallback">
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px' }}>
|
|
||||||
<Badge tone="warn" dot>
|
|
||||||
webhooks down
|
|
||||||
</Badge>
|
|
||||||
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)' }}>
|
|
||||||
polling every 2 min · updates may lag
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Specimen>
|
|
||||||
</div>
|
|
||||||
<Specimen label="first reconcile failed · full-screen">
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '8px 0 20px' }}>
|
|
||||||
<EmptyState
|
|
||||||
compact
|
|
||||||
icon="refresh-cw"
|
|
||||||
title="The reconcile failed"
|
|
||||||
line="Gitea answered, then hung up mid-sentence. Your cache is intact; nothing human is lost."
|
|
||||||
/>
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: -6 }}>
|
|
||||||
<Button>Try again</Button>
|
|
||||||
<Button variant="ghost">Work from cache</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Specimen>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export * from './tag.js'
|
|||||||
export * from './card.js'
|
export * from './card.js'
|
||||||
export * from './tabs.js'
|
export * from './tabs.js'
|
||||||
export * from './input.js'
|
export * from './input.js'
|
||||||
|
export * from './select.js'
|
||||||
export * from './checkbox.js'
|
export * from './checkbox.js'
|
||||||
export * from './radio.js'
|
export * from './radio.js'
|
||||||
export * from './switch.js'
|
export * from './switch.js'
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface InputProps {
|
|||||||
value?: string;
|
value?: string;
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||||
|
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
type?: string;
|
type?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
|||||||
@@ -1,533 +0,0 @@
|
|||||||
/**
|
|
||||||
* Demo fixtures mirrored from the design handoff's `data.js`. Stand-ins until
|
|
||||||
* P1 fills the SQLite cache and P4 wires the real agent. Replace, don't extend
|
|
||||||
* into production code.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ChatMessage {
|
|
||||||
from: 'agent' | 'user'
|
|
||||||
text: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CHAT: ChatMessage[] = [
|
|
||||||
{ from: 'agent', text: 'Morning service. Two things drifted overnight; one needs your opinion.' },
|
|
||||||
{
|
|
||||||
from: 'agent',
|
|
||||||
text: '#84 grew a dependency on #92 — I’ve reordered. And #87 has been steeping for four days; it blocks two others. Worth a look.',
|
|
||||||
},
|
|
||||||
{ from: 'user', text: 'Push the calibration work to next week, pilots come first' },
|
|
||||||
{
|
|
||||||
from: 'agent',
|
|
||||||
text: 'Done — #78 moves to next week. Milestone Beta is unmoved; the cone doesn’t care for calibration either. Logged as a directive.',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const CANNED_REPLY =
|
|
||||||
'Noted and logged as a directive. The scheduler is re-running — I’ll show you the consequence diff in a moment.'
|
|
||||||
|
|
||||||
export const TODAY = 'Tuesday 7 July 2026'
|
|
||||||
|
|
||||||
export interface FocusIssue {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
labels: string[]
|
|
||||||
steeping?: string
|
|
||||||
rationale: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FocusData {
|
|
||||||
now: FocusIssue
|
|
||||||
next: FocusIssue
|
|
||||||
later: FocusIssue
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FOCUS: FocusData = {
|
|
||||||
now: {
|
|
||||||
id: 87,
|
|
||||||
title: 'Fix lifecycle inference on merge events',
|
|
||||||
labels: ['est/2d', 'p/1'],
|
|
||||||
steeping: '4d',
|
|
||||||
rationale: 'It blocks #91 and #92. I’d take it first — the critical path agrees with me.',
|
|
||||||
},
|
|
||||||
next: {
|
|
||||||
id: 91,
|
|
||||||
title: 'Webhook listener: reconcile on reconnect',
|
|
||||||
labels: ['est/3d', 'p/2'],
|
|
||||||
rationale: 'Ready the moment #87 lands. The estimate is yours; history says add a day.',
|
|
||||||
},
|
|
||||||
later: {
|
|
||||||
id: 78,
|
|
||||||
title: 'Calibration store cold-start distributions',
|
|
||||||
labels: ['est/5d', 'p/3'],
|
|
||||||
rationale: 'Nothing depends on it yet. It can steep.',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DriftItem {
|
|
||||||
tone: 'ok' | 'warn' | 'danger'
|
|
||||||
text: string
|
|
||||||
delta: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlanItem {
|
|
||||||
who: string
|
|
||||||
pick: string
|
|
||||||
title: string
|
|
||||||
why: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Nag {
|
|
||||||
id: number
|
|
||||||
days: string
|
|
||||||
blocks: string[]
|
|
||||||
text: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StandupData {
|
|
||||||
date: string
|
|
||||||
drift: DriftItem[]
|
|
||||||
plan: PlanItem[]
|
|
||||||
nag: Nag
|
|
||||||
}
|
|
||||||
|
|
||||||
export const STANDUP: StandupData = {
|
|
||||||
date: 'Tuesday 7 July 2026',
|
|
||||||
drift: [
|
|
||||||
{ tone: 'warn', text: '#84 grew a dependency on #92 overnight.', delta: 'reordered · no date impact' },
|
|
||||||
{ tone: 'warn', text: '#92 has sat in review for two days.', delta: 'Beta 80% +1d if idle past Thu' },
|
|
||||||
{ tone: 'ok', text: 'Webhook outage 02:14–02:31; full reconcile ran.', delta: 'nothing lost' },
|
|
||||||
],
|
|
||||||
plan: [
|
|
||||||
{
|
|
||||||
who: 'Stephen',
|
|
||||||
pick: '#87',
|
|
||||||
title: 'Fix lifecycle inference on merge events',
|
|
||||||
why: 'It blocks two others and the critical path runs straight through it.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
who: 'Ana K.',
|
|
||||||
pick: '#84',
|
|
||||||
title: 'Capacity model: focus factor per person',
|
|
||||||
why: 'Already steeping — finish it before anything new is poured.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
nag: {
|
|
||||||
id: 87,
|
|
||||||
days: '4d',
|
|
||||||
blocks: ['#91', '#92'],
|
|
||||||
text: 'Four days is a long steep. It blocks #91 and #92 — worth a look before it stains.',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lightweight issue reference handed to the shell's drill-in from any screen. */
|
|
||||||
export interface IssueRef {
|
|
||||||
id: number
|
|
||||||
title?: string
|
|
||||||
labels?: string[]
|
|
||||||
days?: string
|
|
||||||
rationale?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Board (The pot) ----
|
|
||||||
|
|
||||||
export interface BoardIssue {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
labels: string[]
|
|
||||||
who: string
|
|
||||||
blocked?: boolean
|
|
||||||
days?: string
|
|
||||||
pr?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BoardColumn {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
issues: BoardIssue[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const COLUMNS: BoardColumn[] = [
|
|
||||||
{
|
|
||||||
id: 'diagnosis',
|
|
||||||
label: 'Diagnosis',
|
|
||||||
issues: [
|
|
||||||
{ id: 102, title: 'Scheduler ignores standing allocation slices', labels: ['p/2'], who: 'SM' },
|
|
||||||
{ id: 103, title: 'Dark theme: cone fill too faint', labels: ['p/4'], who: 'SM' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'triage',
|
|
||||||
label: 'Triage',
|
|
||||||
issues: [
|
|
||||||
{ id: 99, title: 'Directive log: conflict-free merge test', labels: ['est/1d', 'p/2'], who: 'SM' },
|
|
||||||
{ id: 96, title: 'Label schema bootstrap for new repos', labels: ['est/2d', 'p/3'], who: 'AK' },
|
|
||||||
{ id: 78, title: 'Calibration store cold-start distributions', labels: ['est/5d', 'p/3'], who: 'SM' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'steeping',
|
|
||||||
label: 'Steeping',
|
|
||||||
issues: [
|
|
||||||
{ id: 87, title: 'Fix lifecycle inference on merge events', labels: ['est/2d', 'p/1'], who: 'SM', blocked: false, days: '4d' },
|
|
||||||
{ id: 84, title: 'Capacity model: focus factor per person', labels: ['est/3d', 'p/2'], who: 'AK', days: '1d' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'review',
|
|
||||||
label: 'In review',
|
|
||||||
issues: [{ id: 92, title: 'Monte Carlo engine: percentile bands', labels: ['est/5d', 'p/1'], who: 'SM', pr: '#141' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'done',
|
|
||||||
label: 'Done',
|
|
||||||
issues: [
|
|
||||||
{ id: 71, title: 'Gitea client: token auth + retries', labels: ['est/2d', 'p/2'], who: 'SM' },
|
|
||||||
{ id: 69, title: 'pm-state repo bootstrap', labels: ['est/1d', 'p/1'], who: 'SM' },
|
|
||||||
{ id: 65, title: 'Electron shell + window state', labels: ['est/3d', 'p/2'], who: 'AK' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// ---- Gantt ----
|
|
||||||
|
|
||||||
export interface GanttWeek {
|
|
||||||
at: number
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GanttRow {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
who: string
|
|
||||||
state: 'done' | 'steeping' | 'review' | 'scheduled'
|
|
||||||
start: number
|
|
||||||
end: number
|
|
||||||
p80?: number
|
|
||||||
crit?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GanttData {
|
|
||||||
days: number
|
|
||||||
weeks: GanttWeek[]
|
|
||||||
today: number
|
|
||||||
band: { from: number; to: number; label: string }
|
|
||||||
due: { at: number; label: string }
|
|
||||||
rows: GanttRow[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GANTT: GanttData = {
|
|
||||||
days: 42,
|
|
||||||
weeks: [
|
|
||||||
{ at: 0, label: 'Feb 2' },
|
|
||||||
{ at: 7, label: 'Feb 9' },
|
|
||||||
{ at: 14, label: 'Feb 16' },
|
|
||||||
{ at: 21, label: 'Feb 23' },
|
|
||||||
{ at: 28, label: 'Mar 2' },
|
|
||||||
{ at: 35, label: 'Mar 9' },
|
|
||||||
{ at: 41, label: 'Mar 15' },
|
|
||||||
],
|
|
||||||
today: 8,
|
|
||||||
band: { from: 29, to: 38, label: '80% · Mar 3–12' },
|
|
||||||
due: { at: 41, label: 'Beta due' },
|
|
||||||
rows: [
|
|
||||||
{ id: 71, title: 'Gitea client: token auth + retries', who: 'SM', state: 'done', start: 0, end: 4 },
|
|
||||||
{ id: 69, title: 'pm-state repo bootstrap', who: 'SM', state: 'done', start: 0, end: 1 },
|
|
||||||
{ id: 87, title: 'Fix lifecycle inference on merge events', who: 'SM', state: 'steeping', start: 4, end: 9, p80: 11, crit: true },
|
|
||||||
{ id: 92, title: 'Monte Carlo engine: percentile bands', who: 'SM', state: 'review', start: 9, end: 18, p80: 21, crit: true },
|
|
||||||
{ id: 91, title: 'Webhook listener: reconcile on reconnect', who: 'AK', state: 'scheduled', start: 9, end: 14, p80: 16 },
|
|
||||||
{ id: 99, title: 'Directive log: conflict-free merge test', who: 'SM', state: 'scheduled', start: 10, end: 11 },
|
|
||||||
{ id: 96, title: 'Label schema bootstrap for new repos', who: 'AK', state: 'scheduled', start: 14, end: 16 },
|
|
||||||
{ id: 84, title: 'Capacity model: focus factor per person', who: 'AK', state: 'scheduled', start: 18, end: 23, p80: 26, crit: true },
|
|
||||||
{ id: 102, title: 'Scheduler ignores standing allocation slices', who: 'SM', state: 'scheduled', start: 23, end: 25, p80: 28, crit: true },
|
|
||||||
{ id: 78, title: 'Calibration store cold-start distributions', who: 'SM', state: 'scheduled', start: 25, end: 30 },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Dependencies ----
|
|
||||||
|
|
||||||
export interface DepNode {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
tags: string[]
|
|
||||||
state: 'done' | 'steeping' | 'review' | 'triage' | 'diagnosis'
|
|
||||||
days?: string
|
|
||||||
col: number
|
|
||||||
row: number
|
|
||||||
rationale?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DepEdge {
|
|
||||||
from: number
|
|
||||||
to: number | 'ms'
|
|
||||||
crit?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DepsData {
|
|
||||||
nodes: DepNode[]
|
|
||||||
milestone: { name: string; due: string; col: number; row: number }
|
|
||||||
edges: DepEdge[]
|
|
||||||
critical: number[]
|
|
||||||
unattached: number[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEPS: DepsData = {
|
|
||||||
nodes: [
|
|
||||||
{ id: 71, title: 'Gitea client: token auth + retries', tags: ['est/2d'], state: 'done', col: 0, row: 0.2 },
|
|
||||||
{ id: 69, title: 'pm-state repo bootstrap', tags: ['est/1d'], state: 'done', col: 0, row: 1.9 },
|
|
||||||
{
|
|
||||||
id: 87,
|
|
||||||
title: 'Fix lifecycle inference on merge events',
|
|
||||||
tags: ['est/2d', 'p/1'],
|
|
||||||
state: 'steeping',
|
|
||||||
days: '4d',
|
|
||||||
col: 1,
|
|
||||||
row: 0.7,
|
|
||||||
rationale: 'It blocks #91 and #92. I’d take it first — the critical path agrees with me.',
|
|
||||||
},
|
|
||||||
{ id: 99, title: 'Directive log: conflict-free merge test', tags: ['est/1d', 'p/2'], state: 'triage', col: 1, row: 1.9 },
|
|
||||||
{ id: 91, title: 'Webhook listener: reconcile on reconnect', tags: ['est/3d', 'p/2'], state: 'triage', col: 2, row: 0 },
|
|
||||||
{ id: 92, title: 'Monte Carlo engine: percentile bands', tags: ['est/5d', 'p/1'], state: 'review', col: 2, row: 1.4 },
|
|
||||||
{ id: 84, title: 'Capacity model: focus factor per person', tags: ['est/3d', 'p/2'], state: 'steeping', days: '1d', col: 3, row: 1.4 },
|
|
||||||
{ id: 102, title: 'Scheduler ignores standing allocation slices', tags: ['p/2'], state: 'diagnosis', col: 4, row: 1.4 },
|
|
||||||
],
|
|
||||||
milestone: { name: 'Beta', due: 'Mar 15', col: 5, row: 1.4 },
|
|
||||||
edges: [
|
|
||||||
{ from: 71, to: 87 },
|
|
||||||
{ from: 69, to: 99 },
|
|
||||||
{ from: 87, to: 91 },
|
|
||||||
{ from: 87, to: 92, crit: true },
|
|
||||||
{ from: 92, to: 84, crit: true },
|
|
||||||
{ from: 84, to: 102, crit: true },
|
|
||||||
{ from: 102, to: 'ms', crit: true },
|
|
||||||
],
|
|
||||||
critical: [87, 92, 84, 102],
|
|
||||||
unattached: [78, 96, 103, 65],
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Runway ----
|
|
||||||
|
|
||||||
export interface RunwayMilestone {
|
|
||||||
name: string
|
|
||||||
due: string
|
|
||||||
hard: boolean
|
|
||||||
p80: string
|
|
||||||
pos: number
|
|
||||||
spread: number
|
|
||||||
tone: 'ok' | 'warn'
|
|
||||||
note: string
|
|
||||||
/** gitea milestone id, for the drill-in (absent on the demo fixture). */
|
|
||||||
id?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RUNWAY: RunwayMilestone[] = [
|
|
||||||
{ name: 'Beta', due: 'Mar 15', hard: false, p80: 'Mar 3–12', pos: 0.62, spread: 0.18, tone: 'ok', note: 'ahead' },
|
|
||||||
{ name: 'Pilot-ready', due: 'Apr 30', hard: true, p80: 'Apr 21 – May 9', pos: 0.94, spread: 0.26, tone: 'warn', note: 'drifting' },
|
|
||||||
{ name: 'v1.0', due: 'Jun 12', hard: false, p80: 'May 30 – Jun 20', pos: 0.88, spread: 0.3, tone: 'ok', note: 'on watch' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export interface CapacityMember {
|
|
||||||
who: string
|
|
||||||
hours: string
|
|
||||||
focus: number
|
|
||||||
slices: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CAPACITY: CapacityMember[] = [
|
|
||||||
{ who: 'Stephen', hours: '5.2h/day', focus: 0.65, slices: 'dev 70% · pilots 20% · compliance 10%' },
|
|
||||||
{ who: 'Ana K.', hours: '3.8h/day', focus: 0.8, slices: 'dev 100%' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// ---- Calibration ----
|
|
||||||
|
|
||||||
export interface CalLabel {
|
|
||||||
label: string
|
|
||||||
n: number
|
|
||||||
median: string
|
|
||||||
bias: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CalPerson {
|
|
||||||
who: string
|
|
||||||
n: number
|
|
||||||
bias: number
|
|
||||||
note: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CalibrationData {
|
|
||||||
n: number
|
|
||||||
active: boolean
|
|
||||||
labels: CalLabel[]
|
|
||||||
people: CalPerson[]
|
|
||||||
scatter: number[][]
|
|
||||||
fit: number
|
|
||||||
effect: { raw: string; banded: string; p50: string }
|
|
||||||
/** Closed+estimated issues that can't calibrate (same-day / 0-day closes). */
|
|
||||||
excludedSameDay: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CALIBRATION: CalibrationData = {
|
|
||||||
n: 27,
|
|
||||||
active: true,
|
|
||||||
excludedSameDay: 0,
|
|
||||||
labels: [
|
|
||||||
{ label: 'est/1d', n: 8, median: '1.1d', bias: 8 },
|
|
||||||
{ label: 'est/2d', n: 9, median: '2.4d', bias: 18 },
|
|
||||||
{ label: 'est/3d', n: 6, median: '3.7d', bias: 22 },
|
|
||||||
{ label: 'est/5d', n: 3, median: '6.5d', bias: 30 },
|
|
||||||
{ label: 'est/8d', n: 1, median: '8.5d', bias: null },
|
|
||||||
],
|
|
||||||
people: [
|
|
||||||
{ who: 'Stephen', n: 19, bias: 21, note: 'optimism grows with ticket size' },
|
|
||||||
{ who: 'Ana K.', n: 8, bias: 9, note: 'close to honest — suspicious' },
|
|
||||||
],
|
|
||||||
scatter: [
|
|
||||||
[1, 1], [1, 1.2], [1, 0.9], [1, 1.5], [1, 1.1], [1, 0.8], [1, 1.3], [1, 1.2],
|
|
||||||
[2, 2], [2, 2.5], [2, 3.1], [2, 2.2], [2, 2.6], [2, 1.8], [2, 2.4], [2, 2.9], [2, 2.3],
|
|
||||||
[3, 3.5], [3, 4.1], [3, 3.1], [3, 4.6], [3, 3.6], [3, 3.9],
|
|
||||||
[5, 6.1], [5, 7.2], [5, 6.4],
|
|
||||||
[8, 8.5],
|
|
||||||
],
|
|
||||||
fit: 1.18,
|
|
||||||
effect: { raw: '42d of estimates', banded: '80% band 46–53d', p50: '48d' },
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Inbox ----
|
|
||||||
|
|
||||||
export interface InboxIssueRef {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
labels: string[]
|
|
||||||
days?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InboxItem {
|
|
||||||
id: number
|
|
||||||
day: string
|
|
||||||
type: string
|
|
||||||
icon: string
|
|
||||||
tone: 'ok' | 'warn' | 'info' | 'neutral'
|
|
||||||
who?: string
|
|
||||||
text: string
|
|
||||||
detail: string
|
|
||||||
time: string
|
|
||||||
unread: boolean
|
|
||||||
to?: string
|
|
||||||
issue?: InboxIssueRef
|
|
||||||
}
|
|
||||||
|
|
||||||
export const INBOX: InboxItem[] = [
|
|
||||||
{ id: 1, day: 'Today', type: 'drift', icon: 'chart-line', tone: 'warn', text: 'Beta’s 80% window moved', detail: 'Mar 3–12 → Mar 5–14 · directive #007', time: '09:14', unread: true, to: 'directives' },
|
|
||||||
{ id: 2, day: 'Today', type: 'mention', icon: 'message-square', tone: 'info', who: 'Ana K.', text: 'mentioned you on #92', detail: '“percentile bands ready for a second pair of eyes”', time: '08:52', unread: true, issue: { id: 92, title: 'Monte Carlo engine: percentile bands', labels: ['est/5d', 'p/1'] } },
|
|
||||||
{ id: 3, day: 'Today', type: 'nag', icon: 'clock', tone: 'warn', text: '#87 is steeping · 4d', detail: 'blocks #91 and #92 — worth a look before it stains', time: '07:00', unread: true, issue: { id: 87, title: 'Fix lifecycle inference on merge events', labels: ['est/2d', 'p/1'], days: '4d' } },
|
|
||||||
{ id: 4, day: 'Yesterday', type: 'review', icon: 'git-pull-request', tone: 'info', text: 'PR #141 awaits review', detail: '#92 · idle two days', time: '16:20', unread: false, issue: { id: 92, title: 'Monte Carlo engine: percentile bands', labels: ['est/5d', 'p/1'] } },
|
|
||||||
{ id: 5, day: 'Yesterday', type: 'assignment', icon: 'user', tone: 'neutral', who: 'Ana K.', text: 'took #96 from the pot', detail: 'Label schema bootstrap for new repos', time: '11:03', unread: false, issue: { id: 96, title: 'Label schema bootstrap for new repos', labels: ['est/2d', 'p/3'] } },
|
|
||||||
{ id: 6, day: 'Yesterday', type: 'system', icon: 'refresh-cw', tone: 'ok', text: 'Webhook outage 02:14–02:31', detail: 'full reconcile ran · nothing lost', time: '02:31', unread: false },
|
|
||||||
{ id: 7, day: 'Yesterday', type: 'milestone', icon: 'milestone', tone: 'ok', text: 'P2 — Scheduler closed two days early', detail: 'the calibration noticed. So did I.', time: '09:40', unread: false },
|
|
||||||
]
|
|
||||||
|
|
||||||
// ---- Issue detail ----
|
|
||||||
|
|
||||||
export interface IssueComment {
|
|
||||||
who: string
|
|
||||||
when: string
|
|
||||||
text: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IssueLifecycleStage {
|
|
||||||
stage: string
|
|
||||||
event: string
|
|
||||||
when: string
|
|
||||||
icon: string
|
|
||||||
done: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IssueDetail {
|
|
||||||
state: string
|
|
||||||
assignee: string
|
|
||||||
milestone: string
|
|
||||||
body: string
|
|
||||||
comments: IssueComment[]
|
|
||||||
lifecycle: IssueLifecycleStage[]
|
|
||||||
forecast: { p80: string; note: string }
|
|
||||||
blocks: number[]
|
|
||||||
blockedBy: number[]
|
|
||||||
note: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ISSUE_DETAIL: Record<number, IssueDetail> = {
|
|
||||||
87: {
|
|
||||||
state: 'steeping',
|
|
||||||
assignee: 'Stephen',
|
|
||||||
milestone: 'Beta',
|
|
||||||
body: 'Squash-merges emit events out of order when CI runs long: the PR-closed webhook lands before the merge event, so lifecycle inference marks deploy before work-end and the actuals go negative. Regular merges infer correctly.',
|
|
||||||
comments: [
|
|
||||||
{ who: 'Ana K.', when: 'Feb 8 · 14:12', text: 'Repro: squash-merge only. Regular merges infer fine.' },
|
|
||||||
{ who: 'Stephen', when: 'Feb 9 · 09:30', text: 'Confirmed — the event order flips whenever CI takes more than ~5 minutes.' },
|
|
||||||
],
|
|
||||||
lifecycle: [
|
|
||||||
{ stage: 'Diagnosis', event: 'issue opened', when: 'Feb 2 · 09:14', icon: 'circle-dot', done: true },
|
|
||||||
{ stage: 'Triage', event: 'labeled est/2d · milestoned Beta', when: 'Feb 3 · 10:02', icon: 'tag', done: true },
|
|
||||||
{ stage: 'Work start', event: 'first commit ref a41f09', when: 'Feb 6 · 11:47', icon: 'git-commit-horizontal', done: true },
|
|
||||||
{ 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: 'done Feb 11–13', note: 'from your est/2d history · n=14' },
|
|
||||||
blocks: [91, 92],
|
|
||||||
blockedBy: [],
|
|
||||||
note: 'It blocks #91 and #92. I’d take it first — the critical path agrees with me.',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Directives ----
|
|
||||||
|
|
||||||
export interface DirectiveDiffRow {
|
|
||||||
tone: 'ok' | 'warn' | 'info' | 'danger'
|
|
||||||
change: string
|
|
||||||
from: string
|
|
||||||
to: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DirectivePending {
|
|
||||||
seq: number
|
|
||||||
who: string
|
|
||||||
when: string
|
|
||||||
what: string
|
|
||||||
diff: DirectiveDiffRow[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DirectiveEntry {
|
|
||||||
seq: number
|
|
||||||
who: string
|
|
||||||
when: string
|
|
||||||
what: string
|
|
||||||
why: string
|
|
||||||
status: string
|
|
||||||
consequence: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DirectivesData {
|
|
||||||
pending: DirectivePending | null
|
|
||||||
entries: DirectiveEntry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DIRECTIVES: DirectivesData = {
|
|
||||||
pending: {
|
|
||||||
seq: 7,
|
|
||||||
who: 'Stephen',
|
|
||||||
when: 'today 09:12',
|
|
||||||
what: 'Pilots before calibration — push #78 to next week.',
|
|
||||||
diff: [
|
|
||||||
{ tone: 'info', change: '#78 Calibration store', from: 'this week', to: 'wk of Feb 23' },
|
|
||||||
{ tone: 'warn', change: 'Beta · 80% window', from: 'Mar 3–12', to: 'Mar 5–14' },
|
|
||||||
{ tone: 'ok', change: "Today's plan", from: '#87', to: '#87 · unchanged' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
entries: [
|
|
||||||
{ seq: 6, who: 'Stephen', when: 'Feb 8 · 16:40', what: 'Ana takes nothing new until #84 lands.', why: 'context thrash', status: 'applied', consequence: 'WIP capped · v1.0 unmoved' },
|
|
||||||
{ seq: 5, who: 'Stephen', when: 'Feb 6 · 09:03', what: 'Ship Beta a week early.', why: 'board meeting', status: 'withdrawn', consequence: '80% would need scope −9d — withdrawn after diff' },
|
|
||||||
{ seq: 4, who: 'Stephen', when: 'Feb 3 · 11:21', what: 'deadline/hard on Pilot-ready.', why: 'contract date', status: 'applied', consequence: 'label applied · runway flag raised' },
|
|
||||||
{ seq: 3, who: 'Stephen', when: 'Jan 28 · 08:47', what: 'Webhook work ahead of UI polish.', why: '', status: 'applied', consequence: '#91 +2 ranks · Beta unmoved' },
|
|
||||||
{ seq: 2, who: 'Stephen', when: 'Jan 20 · 14:02', what: 'Estimates in days, never hours.', why: 'sanity', status: 'applied', consequence: 'label schema est/* confirmed' },
|
|
||||||
{ seq: 1, who: 'Stephen', when: 'Jan 19 · 09:00', what: 'CommiTea manages its own backlog.', why: 'dogfood', status: 'applied', consequence: 'stephen/commitea under management' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
237
apps/desktop/src/renderer/src/data/view-types.ts
Normal file
237
apps/desktop/src/renderer/src/data/view-types.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* View-model types — the contract between the renderer's view builders
|
||||||
|
* (`lib/views/*`, `lib/backlog.ts`) and the screens that render their output.
|
||||||
|
* These are screen-shaped (post-builder) types only; there is no demo data here.
|
||||||
|
* Live data flows in through the main-process bridge and the builders; when a
|
||||||
|
* builder has nothing to produce, the screen renders an EmptyState.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
from: 'agent' | 'user'
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FocusIssue {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
labels: string[]
|
||||||
|
steeping?: string
|
||||||
|
rationale: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FocusData {
|
||||||
|
now: FocusIssue
|
||||||
|
next: FocusIssue
|
||||||
|
later: FocusIssue
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriftItem {
|
||||||
|
tone: 'ok' | 'warn' | 'danger'
|
||||||
|
text: string
|
||||||
|
delta: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanItem {
|
||||||
|
who: string
|
||||||
|
pick: string
|
||||||
|
title: string
|
||||||
|
why: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Nag {
|
||||||
|
id: number
|
||||||
|
days: string
|
||||||
|
blocks: string[]
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StandupData {
|
||||||
|
date: string
|
||||||
|
drift: DriftItem[]
|
||||||
|
plan: PlanItem[]
|
||||||
|
nag: Nag
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lightweight issue reference handed to the shell's drill-in from any screen. */
|
||||||
|
export interface IssueRef {
|
||||||
|
id: number
|
||||||
|
title?: string
|
||||||
|
labels?: string[]
|
||||||
|
days?: string
|
||||||
|
rationale?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Board (The pot) ----
|
||||||
|
|
||||||
|
export interface BoardIssue {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
labels: string[]
|
||||||
|
who: string
|
||||||
|
blocked?: boolean
|
||||||
|
days?: string
|
||||||
|
pr?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardColumn {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
issues: BoardIssue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Gantt ----
|
||||||
|
|
||||||
|
export interface GanttWeek {
|
||||||
|
at: number
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GanttRow {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
who: string
|
||||||
|
state: 'done' | 'steeping' | 'review' | 'scheduled'
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
p80?: number
|
||||||
|
crit?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GanttData {
|
||||||
|
days: number
|
||||||
|
weeks: GanttWeek[]
|
||||||
|
today: number
|
||||||
|
band: { from: number; to: number; label: string }
|
||||||
|
due: { at: number; label: string }
|
||||||
|
rows: GanttRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Dependencies ----
|
||||||
|
|
||||||
|
export interface DepNode {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
tags: string[]
|
||||||
|
state: 'done' | 'steeping' | 'review' | 'triage' | 'diagnosis'
|
||||||
|
days?: string
|
||||||
|
col: number
|
||||||
|
row: number
|
||||||
|
rationale?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DepEdge {
|
||||||
|
from: number
|
||||||
|
to: number | 'ms'
|
||||||
|
crit?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DepsData {
|
||||||
|
nodes: DepNode[]
|
||||||
|
milestone: { name: string; due: string; col: number; row: number }
|
||||||
|
edges: DepEdge[]
|
||||||
|
critical: number[]
|
||||||
|
unattached: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Runway ----
|
||||||
|
|
||||||
|
export interface RunwayMilestone {
|
||||||
|
name: string
|
||||||
|
due: string
|
||||||
|
hard: boolean
|
||||||
|
p80: string
|
||||||
|
pos: number
|
||||||
|
spread: number
|
||||||
|
tone: 'ok' | 'warn'
|
||||||
|
note: string
|
||||||
|
/** gitea milestone id, for the drill-in. */
|
||||||
|
id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapacityMember {
|
||||||
|
who: string
|
||||||
|
hours: string
|
||||||
|
focus: number
|
||||||
|
slices: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Calibration ----
|
||||||
|
|
||||||
|
export interface CalLabel {
|
||||||
|
label: string
|
||||||
|
n: number
|
||||||
|
median: string
|
||||||
|
bias: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalPerson {
|
||||||
|
who: string
|
||||||
|
n: number
|
||||||
|
bias: number
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalibrationData {
|
||||||
|
n: number
|
||||||
|
active: boolean
|
||||||
|
labels: CalLabel[]
|
||||||
|
people: CalPerson[]
|
||||||
|
scatter: number[][]
|
||||||
|
fit: number
|
||||||
|
effect: { raw: string; banded: string; p50: string }
|
||||||
|
/** Closed+estimated issues that can't calibrate (same-day / 0-day closes). */
|
||||||
|
excludedSameDay: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Inbox ----
|
||||||
|
|
||||||
|
export interface InboxIssueRef {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
labels: string[]
|
||||||
|
days?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InboxItem {
|
||||||
|
id: number
|
||||||
|
day: string
|
||||||
|
type: string
|
||||||
|
icon: string
|
||||||
|
tone: 'ok' | 'warn' | 'info' | 'neutral'
|
||||||
|
who?: string
|
||||||
|
text: string
|
||||||
|
detail: string
|
||||||
|
time: string
|
||||||
|
unread: boolean
|
||||||
|
to?: string
|
||||||
|
issue?: InboxIssueRef
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Issue detail ----
|
||||||
|
|
||||||
|
export interface IssueComment {
|
||||||
|
who: string
|
||||||
|
when: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssueLifecycleStage {
|
||||||
|
stage: string
|
||||||
|
event: string
|
||||||
|
when: string
|
||||||
|
icon: string
|
||||||
|
done: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssueDetail {
|
||||||
|
state: string
|
||||||
|
assignee: string
|
||||||
|
milestone: string
|
||||||
|
body: string
|
||||||
|
comments: IssueComment[]
|
||||||
|
lifecycle: IssueLifecycleStage[]
|
||||||
|
forecast: { p80: string; note: string }
|
||||||
|
blocks: number[]
|
||||||
|
blockedBy: number[]
|
||||||
|
note: string
|
||||||
|
}
|
||||||
12
apps/desktop/src/renderer/src/global.d.ts
vendored
12
apps/desktop/src/renderer/src/global.d.ts
vendored
@@ -70,9 +70,21 @@ export interface ConfigInput {
|
|||||||
modelUrl?: string
|
modelUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The result of token-scoped discovery for the onboarding pickers. */
|
||||||
|
export type DiscoverResult =
|
||||||
|
| { ok: false; error?: string }
|
||||||
|
| { ok: true; user: string; owners: string[]; reposByOwner: Record<string, string[]> }
|
||||||
|
|
||||||
|
/** What the first-run bootstrap changed (label schema + pm-state sidecar). */
|
||||||
|
export type BootstrapResult =
|
||||||
|
| { ok: false; error?: string }
|
||||||
|
| { ok: true; labels: { created: string[]; existing: string[] }; pmState: 'created' | 'exists'; pmStateRepo: string }
|
||||||
|
|
||||||
/** The config bridge for team onboarding. */
|
/** The config bridge for team onboarding. */
|
||||||
export interface ConfigBridge {
|
export interface ConfigBridge {
|
||||||
get(): Promise<PublicConfig | null>
|
get(): Promise<PublicConfig | null>
|
||||||
|
discover(conn: { baseUrl: string; token: string }): Promise<DiscoverResult>
|
||||||
|
bootstrap(req: { baseUrl: string; token: string; owner: string; repo: string; underOrg: boolean }): Promise<BootstrapResult>
|
||||||
test(cfg: ConfigInput): Promise<{ ok: boolean; error?: string }>
|
test(cfg: ConfigInput): Promise<{ ok: boolean; error?: string }>
|
||||||
set(cfg: ConfigInput): Promise<{ ok: boolean }>
|
set(cfg: ConfigInput): Promise<{ ok: boolean }>
|
||||||
clear(): Promise<{ ok: boolean }>
|
clear(): Promise<{ ok: boolean }>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
type CalibrationData,
|
type CalibrationData,
|
||||||
type FocusIssue,
|
type FocusIssue,
|
||||||
type RunwayMilestone,
|
type RunwayMilestone,
|
||||||
} from '../data/fixtures.js'
|
} from '../data/view-types.js'
|
||||||
import { addWorkingDays, type BurnUpData, buildBurnUpData, formatRange, formatShort } from './dates.js'
|
import { addWorkingDays, type BurnUpData, buildBurnUpData, formatRange, formatShort } from './dates.js'
|
||||||
|
|
||||||
type Timelines = Record<number, LifecycleEvent[]>
|
type Timelines = Record<number, LifecycleEvent[]>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
|
|
||||||
import type { ChangeProposal, ChatMessage as WireMessage, IssueChange } from '@commitea/core'
|
import type { ChangeProposal, ChatMessage as WireMessage, IssueChange } from '@commitea/core'
|
||||||
|
|
||||||
import { CHAT, type ChatMessage } from '../data/fixtures.js'
|
import { type ChatMessage } from '../data/view-types.js'
|
||||||
|
|
||||||
const LIVE_GREETING: ChatMessage = {
|
const LIVE_GREETING: ChatMessage = {
|
||||||
from: 'agent',
|
from: 'agent',
|
||||||
@@ -34,14 +34,14 @@ export interface ChatState {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reginald's conversation. When a model is configured, `send` drives one agent
|
* Reginald's conversation. When a model is configured, `send` drives one agent
|
||||||
* turn through the main-process bridge (which runs the tool loop). Otherwise it
|
* turn through the main-process bridge (which runs the tool loop). With no model,
|
||||||
* echoes the scripted fixture reply, so the layout stays real with no model and
|
* the panel shows a clean "off" greeting and the composer is disabled — no
|
||||||
* fixture e2e is unaffected. The fixture greeting is display-only — only real
|
* scripted replies. The greeting is display-only; only real turns (`convo`) are
|
||||||
* turns (`convo`) are sent to the model as history. `onApplyChange` performs an
|
* sent to the model as history. `onApplyChange` performs an approved write (the
|
||||||
* approved write (the same handler the Issue screen uses — it refetches).
|
* same handler the Issue screen uses — it refetches).
|
||||||
*/
|
*/
|
||||||
export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: boolean }>): ChatState {
|
export function useChat(onApplyChange?: (change: IssueChange) => Promise<{ ok: boolean }>): ChatState {
|
||||||
const [seed, setSeed] = useState<ChatMessage[]>(CHAT)
|
const [seed, setSeed] = useState<ChatMessage[]>([])
|
||||||
const [convo, setConvo] = useState<ChatMessage[]>([])
|
const [convo, setConvo] = useState<ChatMessage[]>([])
|
||||||
const [thinking, setThinking] = useState(false)
|
const [thinking, setThinking] = useState(false)
|
||||||
const [live, setLive] = useState(false)
|
const [live, setLive] = useState(false)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { inferLifecycle, schedule } from '@commitea/core'
|
import { inferLifecycle, schedule } from '@commitea/core'
|
||||||
|
|
||||||
import type { DepEdge, DepNode, DepsData } from '../../data/fixtures.js'
|
import type { DepEdge, DepNode, DepsData } from '../../data/view-types.js'
|
||||||
import { formatShort } from '../dates.js'
|
import { formatShort } from '../dates.js'
|
||||||
import type { ProjectData } from './project-data.js'
|
import type { ProjectData } from './project-data.js'
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { forecast, inferLifecycle, scheduleWithCapacity, workingDaysBetween } from '@commitea/core'
|
import { forecast, inferLifecycle, scheduleWithCapacity, workingDaysBetween } from '@commitea/core'
|
||||||
|
|
||||||
import type { GanttData, GanttRow, GanttWeek } from '../../data/fixtures.js'
|
import type { GanttData, GanttRow, GanttWeek } from '../../data/view-types.js'
|
||||||
import { addWorkingDays, formatShort } from '../dates.js'
|
import { addWorkingDays, formatShort } from '../dates.js'
|
||||||
import type { ProjectData } from './project-data.js'
|
import type { ProjectData } from './project-data.js'
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { inferLifecycle } from '@commitea/core'
|
import { inferLifecycle } from '@commitea/core'
|
||||||
|
|
||||||
import type { InboxItem } from '../../data/fixtures.js'
|
import type { InboxItem } from '../../data/view-types.js'
|
||||||
import type { ProjectData } from './project-data.js'
|
import type { ProjectData } from './project-data.js'
|
||||||
|
|
||||||
const CLOSED_CAP = 5
|
const CLOSED_CAP = 5
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
toDurationModel,
|
toDurationModel,
|
||||||
} from '@commitea/core'
|
} from '@commitea/core'
|
||||||
|
|
||||||
import type { IssueDetail } from '../../data/fixtures.js'
|
import type { IssueDetail } from '../../data/view-types.js'
|
||||||
import { addWorkingDays, formatRange, formatShort } from '../dates.js'
|
import { addWorkingDays, formatRange, formatShort } from '../dates.js'
|
||||||
import type { ProjectData } from './project-data.js'
|
import type { ProjectData } from './project-data.js'
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type ScheduledItem,
|
type ScheduledItem,
|
||||||
} from '@commitea/core'
|
} from '@commitea/core'
|
||||||
|
|
||||||
import type { DriftItem, Nag, PlanItem, StandupData } from '../../data/fixtures.js'
|
import type { DriftItem, Nag, PlanItem, StandupData } from '../../data/view-types.js'
|
||||||
import type { ProjectData } from './project-data.js'
|
import type { ProjectData } from './project-data.js'
|
||||||
|
|
||||||
/** Same shape as backlog.ts's private toSchedulable — not exported there, so copied inline. */
|
/** Same shape as backlog.ts's private toSchedulable — not exported there, so copied inline. */
|
||||||
|
|||||||
14
apps/desktop/vitest.config.ts
Normal file
14
apps/desktop/vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the Electron main process. Node environment only — renderer
|
||||||
|
* (React) is covered by the Playwright e2e suite, not here. `electron` is a
|
||||||
|
* native module that can't be imported outside the Electron runtime, so tests
|
||||||
|
* that touch it mock it (see snapshot-store.test.ts).
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/main/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -17,10 +17,11 @@ export const QUERY_PROJECT_TOOL: ToolDecl = {
|
|||||||
properties: {
|
properties: {
|
||||||
view: {
|
view: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
enum: ['focus', 'board', 'calibration', 'issue', 'search'],
|
enum: ['focus', 'board', 'calibration', 'issue', 'search', 'standup'],
|
||||||
description:
|
description:
|
||||||
'focus = Now/Next/Later; board = issues by lifecycle column; calibration = estimate-vs-actual; ' +
|
'focus = Now/Next/Later; board = issues by lifecycle column; calibration = estimate-vs-actual; ' +
|
||||||
'issue = one issue (needs filters.issueId); search = issues matching filters.query.',
|
'issue = one issue (needs filters.issueId); search = issues matching filters.query; ' +
|
||||||
|
"standup = today's plan + overnight drift + the stalest blocker.",
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
|||||||
@@ -276,6 +276,7 @@ describe('buildProjectView', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('unbuilt views return a notImplemented marker, not fabricated data', () => {
|
it('unbuilt views return a notImplemented marker, not fabricated data', () => {
|
||||||
expect(buildProjectView('standup', undefined, snap, asOf)).toEqual({ notImplemented: 'standup' })
|
expect(buildProjectView('milestone', undefined, snap, asOf)).toEqual({ notImplemented: 'milestone' })
|
||||||
|
expect(buildProjectView('runway', undefined, snap, asOf)).toEqual({ notImplemented: 'runway' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
71
packages/core/src/agent/memory-v0.test.ts
Normal file
71
packages/core/src/agent/memory-v0.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { DirectiveEntry } from '../directives/record-directive-v0.js'
|
||||||
|
import type { Focus, ScheduledItem } from '../scheduler/scheduler-v0.js'
|
||||||
|
import {
|
||||||
|
activeDirectives,
|
||||||
|
assembleHotContext,
|
||||||
|
estimateTokens,
|
||||||
|
HOT_CONTEXT_BUDGET_TOKENS,
|
||||||
|
} from './memory-v0.js'
|
||||||
|
|
||||||
|
const item = (number: number, title: string): ScheduledItem =>
|
||||||
|
({ number, title, labels: [], order: 0, startDay: 0, endDay: 1, durationDays: 1, blockedBy: [], blocks: [], critical: false, rationale: '' })
|
||||||
|
|
||||||
|
const focus: Focus = { now: item(7, 'Fix lifecycle inference'), next: item(8, 'Webhook listener'), later: null }
|
||||||
|
|
||||||
|
const directive = (over: Partial<DirectiveEntry>): DirectiveEntry => ({
|
||||||
|
id: 'd1',
|
||||||
|
ts: '2026-02-10T09:00:00Z',
|
||||||
|
status: 'accepted',
|
||||||
|
kind: 'note',
|
||||||
|
quote: 'pilots come first',
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('memory-v0 (#27)', () => {
|
||||||
|
it('estimateTokens is a slight over-estimate (~4 chars/token)', () => {
|
||||||
|
expect(estimateTokens('')).toBe(0)
|
||||||
|
expect(estimateTokens('abcd')).toBe(1)
|
||||||
|
expect(estimateTokens('a'.repeat(4001))).toBe(1001)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('activeDirectives keeps accepted/amended, most-recent-first', () => {
|
||||||
|
const ds = [
|
||||||
|
directive({ id: 'a', ts: '2026-02-01T00:00:00Z', status: 'accepted', quote: 'old' }),
|
||||||
|
directive({ id: 'b', ts: '2026-02-11T00:00:00Z', status: 'amended', quote: 'new' }),
|
||||||
|
directive({ id: 'c', ts: '2026-02-12T00:00:00Z', status: 'withdrawn', quote: 'gone' }),
|
||||||
|
directive({ id: 'd', ts: '2026-02-09T00:00:00Z', status: 'proposed', quote: 'maybe' }),
|
||||||
|
]
|
||||||
|
expect(activeDirectives(ds).map((d) => d.quote)).toEqual(['new', 'old'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('assembles hot context under the 2k budget even with a huge charter', () => {
|
||||||
|
const huge = 'Charter line that goes on and on. '.repeat(2000) // ~34k tokens
|
||||||
|
const ds = Array.from({ length: 50 }, (_, i) =>
|
||||||
|
directive({ id: `d${i}`, ts: `2026-02-${String((i % 27) + 1).padStart(2, '0')}T00:00:00Z`, quote: `directive number ${i}` }),
|
||||||
|
)
|
||||||
|
const out = assembleHotContext({ charter: huge, directives: ds, focus })
|
||||||
|
|
||||||
|
expect(estimateTokens(out)).toBeLessThanOrEqual(HOT_CONTEXT_BUDGET_TOKENS)
|
||||||
|
// focus (tiny) is always kept; the charter is the part that gets truncated
|
||||||
|
expect(out).toContain('## Focus')
|
||||||
|
expect(out).toContain('#7 Fix lifecycle inference')
|
||||||
|
expect(out).toContain('…') // charter was clamped
|
||||||
|
// at least some recent directives survived
|
||||||
|
expect(out).toContain('## Active directives')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never inlines ticket bodies — only numbers + titles appear for focus', () => {
|
||||||
|
// The assembler takes no issue bodies by construction; focus shows #number title only.
|
||||||
|
const out = assembleHotContext({ charter: 'Ship the beta.', directives: [directive({})], focus })
|
||||||
|
expect(out).toContain('Now: #7 Fix lifecycle inference')
|
||||||
|
expect(out).toContain('[note] pilots come first')
|
||||||
|
expect(out).not.toMatch(/body|description|comment/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('degrades to just focus when there is no charter or directives', () => {
|
||||||
|
const out = assembleHotContext({ charter: '', directives: [], focus })
|
||||||
|
expect(out).toBe(['## Focus', 'Now: #7 Fix lifecycle inference', 'Next: #8 Webhook listener', 'Later: —'].join('\n'))
|
||||||
|
})
|
||||||
|
})
|
||||||
102
packages/core/src/agent/memory-v0.ts
Normal file
102
packages/core/src/agent/memory-v0.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Memory layers, v0 (#27). Reginald's context is tiered so the model always sees
|
||||||
|
* what matters without ever copying ticket data into the prompt:
|
||||||
|
*
|
||||||
|
* - HOT (this module) — charter + active directives + the focus snapshot, packed
|
||||||
|
* under a hard token budget. Assembled fresh each turn; it's the system-prompt seed.
|
||||||
|
* - WARM — the append-only directive/event ledger + periodic digest, in pm-state.
|
||||||
|
* Not inlined; summarized on demand.
|
||||||
|
* - COLD — gitea + the sidecar, reached through `query_project` tools. Ticket bodies,
|
||||||
|
* comments, and per-issue detail live here and are NEVER copied into memory —
|
||||||
|
* the model fetches them by number when it needs them.
|
||||||
|
*
|
||||||
|
* The invariant: HOT stays under budget, and nothing ticket-shaped is inlined.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DirectiveEntry } from '../directives/record-directive-v0.js'
|
||||||
|
import type { Focus } from '../scheduler/scheduler-v0.js'
|
||||||
|
|
||||||
|
/** The hot layer's hard ceiling (#27: hot context assembles under 2k tokens). */
|
||||||
|
export const HOT_CONTEXT_BUDGET_TOKENS = 2000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenizer-free estimate (~4 chars/token). Deliberately a slight over-estimate so
|
||||||
|
* a real tokenizer never exceeds what this predicts — the budget stays safe.
|
||||||
|
*/
|
||||||
|
export function estimateTokens(text: string): number {
|
||||||
|
return Math.ceil(text.length / 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Directives that still bind: accepted or amended, most-recent-first. */
|
||||||
|
export function activeDirectives(all: DirectiveEntry[]): DirectiveEntry[] {
|
||||||
|
return all
|
||||||
|
.filter((d) => d.status === 'accepted' || d.status === 'amended')
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HotContextInputs {
|
||||||
|
/** The project charter markdown (hot-memory seed). */
|
||||||
|
charter: string
|
||||||
|
/** The directive ledger (any status — filtered to active here). */
|
||||||
|
directives: DirectiveEntry[]
|
||||||
|
/** The current Now/Next/Later focus, or null when nothing is scheduled. */
|
||||||
|
focus: Focus | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusBlock(focus: Focus | null): string {
|
||||||
|
if (!focus) return ''
|
||||||
|
const slot = (label: string, item: Focus['now']) => (item ? `${label}: #${item.number} ${item.title}` : `${label}: —`)
|
||||||
|
return ['## Focus', slot('Now', focus.now), slot('Next', focus.next), slot('Later', focus.later)].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function directivesBlock(directives: DirectiveEntry[]): string[] {
|
||||||
|
// one compact line each; the verbatim quote is the payload, kind is the tag
|
||||||
|
return directives.map((d) => `- [${d.kind}] ${d.quote}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Truncate to a token budget on a whitespace boundary, with an ellipsis marker. */
|
||||||
|
function clampToTokens(text: string, budgetTokens: number): string {
|
||||||
|
if (estimateTokens(text) <= budgetTokens) return text
|
||||||
|
const maxChars = Math.max(0, budgetTokens * 4 - 1)
|
||||||
|
const cut = text.slice(0, maxChars)
|
||||||
|
const lastBreak = cut.lastIndexOf('\n')
|
||||||
|
return `${(lastBreak > maxChars * 0.6 ? cut.slice(0, lastBreak) : cut).trimEnd()}\n…`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble the HOT context under `budget` tokens. Priority when space is tight:
|
||||||
|
* the focus snapshot (tiny, always kept) → the most recent active directives
|
||||||
|
* (each while they fit) → the charter fills whatever budget remains (truncated).
|
||||||
|
* Never inlines ticket bodies — only charter text, directive quotes, and focus
|
||||||
|
* titles, all authored/short. Returns a single prompt-ready block.
|
||||||
|
*/
|
||||||
|
export function assembleHotContext(inputs: HotContextInputs, budget = HOT_CONTEXT_BUDGET_TOKENS): string {
|
||||||
|
const focus = focusBlock(inputs.focus)
|
||||||
|
const focusTokens = focus ? estimateTokens(focus) : 0
|
||||||
|
|
||||||
|
// fit the most recent active directives into ~⅔ of what's left after focus
|
||||||
|
const active = activeDirectives(inputs.directives)
|
||||||
|
const directiveCap = Math.max(0, Math.floor((budget - focusTokens) * (2 / 3)))
|
||||||
|
const keptDirectives: string[] = []
|
||||||
|
let directiveTokens = 0
|
||||||
|
for (const line of directivesBlock(active)) {
|
||||||
|
const t = estimateTokens(line) + 1
|
||||||
|
if (directiveTokens + t > directiveCap) break
|
||||||
|
keptDirectives.push(line)
|
||||||
|
directiveTokens += t
|
||||||
|
}
|
||||||
|
const directives = keptDirectives.length ? ['## Active directives', ...keptDirectives].join('\n') : ''
|
||||||
|
|
||||||
|
// Measure the fixed tail (directives + focus, with their joiner) exactly, then
|
||||||
|
// give the charter the true remainder — reserving for the "## Charter" header,
|
||||||
|
// the block joiner, and the truncation ellipsis so the total never exceeds budget.
|
||||||
|
const tail = [directives, focus].filter(Boolean).join('\n\n')
|
||||||
|
const tailTokens = tail ? estimateTokens(tail) : 0
|
||||||
|
const reserve = estimateTokens(`## Charter\n${tail ? '\n\n' : ''}\n…`)
|
||||||
|
const charterBudget = Math.max(0, budget - tailTokens - reserve)
|
||||||
|
const charterBody = inputs.charter.trim() ? clampToTokens(inputs.charter.trim(), charterBudget) : ''
|
||||||
|
const charter = charterBody ? `## Charter\n${charterBody}` : ''
|
||||||
|
|
||||||
|
return [charter, tail].filter(Boolean).join('\n\n')
|
||||||
|
}
|
||||||
74
packages/core/src/agent/query-project.test.ts
Normal file
74
packages/core/src/agent/query-project.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { extractLabelFacts } from '../labels/label-schema.js'
|
||||||
|
import type { LifecycleEvent } from '../lifecycle/lifecycle-v0.js'
|
||||||
|
import type { GiteaIssue } from '../gitea/types.js'
|
||||||
|
import { buildProjectView, type ProjectSnapshot } from './query-project.js'
|
||||||
|
|
||||||
|
const asOf = new Date('2026-02-12T00:00:00Z')
|
||||||
|
|
||||||
|
function issue(over: Partial<GiteaIssue>): GiteaIssue {
|
||||||
|
const labels = over.labels ?? ['est/2d']
|
||||||
|
return {
|
||||||
|
number: 1,
|
||||||
|
title: '#1',
|
||||||
|
body: '',
|
||||||
|
state: 'open',
|
||||||
|
labels,
|
||||||
|
facts: extractLabelFacts(labels),
|
||||||
|
milestone: null,
|
||||||
|
assignee: null,
|
||||||
|
assignees: [],
|
||||||
|
createdAt: '2026-02-02T09:00:00Z',
|
||||||
|
updatedAt: '2026-02-02T09:00:00Z',
|
||||||
|
closedAt: null,
|
||||||
|
url: '',
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildProjectView: standup (#28)', () => {
|
||||||
|
it('surfaces schedule drift + a stale blocker, with a per-person plan', () => {
|
||||||
|
// #7 started work 6 working days ago (steeping) and blocks #8; est is 2d → past estimate.
|
||||||
|
const steeping = issue({ number: 7, labels: ['est/2d', 'p/1'], assignee: 'christian' })
|
||||||
|
const blocked = issue({ number: 8, labels: ['est/3d', 'p/2'], assignee: 'stephen' })
|
||||||
|
const timelines: Record<number, LifecycleEvent[]> = {
|
||||||
|
7: [{ type: 'commit', at: '2026-02-04T09:00:00Z' }],
|
||||||
|
}
|
||||||
|
const snap: ProjectSnapshot = { issues: [steeping, blocked], timelines, deps: [{ issue: 8, dependsOn: 7 }] }
|
||||||
|
|
||||||
|
const v = buildProjectView('standup', undefined, snap, asOf) as {
|
||||||
|
plan: { who: string; issue: number; why: string }[]
|
||||||
|
drift: { issue: number; note: string }[]
|
||||||
|
nag: { issue: number; steepingDays: number; blocks: number[] } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// a stale blocker is nagged, and it's the steeping one that blocks another
|
||||||
|
expect(v.nag).not.toBeNull()
|
||||||
|
expect(v.nag!.issue).toBe(7)
|
||||||
|
expect(v.nag!.steepingDays).toBeGreaterThan(0)
|
||||||
|
expect(v.nag!.blocks).toContain(8)
|
||||||
|
|
||||||
|
// drift caught the past-estimate steep
|
||||||
|
expect(v.drift.some((d) => d.issue === 7)).toBe(true)
|
||||||
|
|
||||||
|
// plan gives an earliest pick per person (both assignees represented)
|
||||||
|
expect(v.plan.map((p) => p.who)).toEqual(expect.arrayContaining(['christian', 'stephen']))
|
||||||
|
// #7 leads its person's plan on the critical path
|
||||||
|
expect(v.plan.find((p) => p.issue === 7)?.why).toContain('critical')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is calm when nothing is steeping (no nag, empty drift)', () => {
|
||||||
|
const snap: ProjectSnapshot = {
|
||||||
|
issues: [issue({ number: 1, labels: ['est/2d'] })],
|
||||||
|
timelines: {},
|
||||||
|
deps: [],
|
||||||
|
}
|
||||||
|
const v = buildProjectView('standup', undefined, snap, asOf) as {
|
||||||
|
drift: unknown[]
|
||||||
|
nag: unknown | null
|
||||||
|
}
|
||||||
|
expect(v.nag).toBeNull()
|
||||||
|
expect(v.drift).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
* deterministic code (scheduler, lifecycle inference, calibration); the model
|
* deterministic code (scheduler, lifecycle inference, calibration); the model
|
||||||
* only requests a shape and narrates it — it never computes (decisions.md).
|
* only requests a shape and narrates it — it never computes (decisions.md).
|
||||||
*
|
*
|
||||||
* v0 serves focus / board / calibration / issue. The remaining views
|
* Serves focus / board / calibration / issue / search / standup. The remaining
|
||||||
* (milestone / runway / standup / search) return a `notImplemented` marker so
|
* views (milestone / runway) return a `notImplemented` marker so the model
|
||||||
* the model degrades honestly instead of inventing data.
|
* degrades honestly instead of inventing data.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fitCalibration, calibrationSamples } from '../calibration/calibration-v0.js'
|
import { fitCalibration, calibrationSamples } from '../calibration/calibration-v0.js'
|
||||||
@@ -121,6 +121,62 @@ function searchView(snap: ProjectSnapshot, filters: QueryFilters) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standup — the morning ritual as a compact, model-narratable payload: today's
|
||||||
|
* plan (the scheduler's earliest pick per person), overnight drift (real
|
||||||
|
* anomalies — issues sitting in review or steeping past their estimate), and the
|
||||||
|
* single stalest blocker to nag about. All from deterministic code; the model
|
||||||
|
* narrates it. Satisfies #28's "surfaces schedule drift + at least one stale blocker".
|
||||||
|
*/
|
||||||
|
function standupView(snap: ProjectSnapshot, asOf: Date) {
|
||||||
|
const plan = schedule(toSchedulable(snap.issues), snap.deps)
|
||||||
|
const open = snap.issues.filter((i) => i.state === 'open')
|
||||||
|
const infOf = (i: GiteaIssue) => inferLifecycle(i, snap.timelines[i.number] ?? [], asOf)
|
||||||
|
|
||||||
|
// plan: the earliest scheduled pick per assignee (dependency + priority order)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const planPicks: { who: string; issue: number; title: string; why: string }[] = []
|
||||||
|
for (const item of plan.items) {
|
||||||
|
const who = open.find((i) => i.number === item.number)?.assignee ?? 'unassigned'
|
||||||
|
if (seen.has(who)) continue
|
||||||
|
seen.add(who)
|
||||||
|
planPicks.push({
|
||||||
|
who,
|
||||||
|
issue: item.number,
|
||||||
|
title: item.title,
|
||||||
|
why: item.critical
|
||||||
|
? 'on the critical path'
|
||||||
|
: item.blocks.length
|
||||||
|
? `blocks ${item.blocks.map((b) => `#${b}`).join(', ')}`
|
||||||
|
: 'next by dependency + priority',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// drift: real overnight anomalies (review-sitting, steeping past estimate)
|
||||||
|
const drift: { issue: number; note: string }[] = []
|
||||||
|
for (const i of open) {
|
||||||
|
const inf = infOf(i)
|
||||||
|
if (inf.column === 'review') drift.push({ issue: i.number, note: `#${i.number} is sitting in review` })
|
||||||
|
else if (inf.steepingDays != null && inf.steepingDays > (i.facts.estimateDays ?? 2))
|
||||||
|
drift.push({
|
||||||
|
issue: i.number,
|
||||||
|
note: `#${i.number} has steeped ${inf.steepingDays}d past its ${i.facts.estimateDays ?? 2}d estimate`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// nag: the single longest-steeping open issue + what it blocks
|
||||||
|
let nag: { issue: number; steepingDays: number; blocks: number[] } | null = null
|
||||||
|
for (const i of open) {
|
||||||
|
const inf = infOf(i)
|
||||||
|
if (inf.steepingDays == null) continue
|
||||||
|
if (!nag || inf.steepingDays > nag.steepingDays) {
|
||||||
|
nag = { issue: i.number, steepingDays: inf.steepingDays, blocks: plan.items.find((it) => it.number === i.number)?.blocks ?? [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { date: asOf.toISOString().slice(0, 10), plan: planPicks.slice(0, 5), drift: drift.slice(0, 5), nag }
|
||||||
|
}
|
||||||
|
|
||||||
/** Build the compact payload for one view. Unknown/unbuilt views return a marker. */
|
/** Build the compact payload for one view. Unknown/unbuilt views return a marker. */
|
||||||
export function buildProjectView(
|
export function buildProjectView(
|
||||||
view: ProjectView,
|
view: ProjectView,
|
||||||
@@ -140,6 +196,8 @@ export function buildProjectView(
|
|||||||
return issueView(snap, f, asOf)
|
return issueView(snap, f, asOf)
|
||||||
case 'search':
|
case 'search':
|
||||||
return searchView(snap, f)
|
return searchView(snap, f)
|
||||||
|
case 'standup':
|
||||||
|
return standupView(snap, asOf)
|
||||||
default:
|
default:
|
||||||
return { notImplemented: view }
|
return { notImplemented: view }
|
||||||
}
|
}
|
||||||
|
|||||||
191
packages/core/src/cache/cache-purity-v0.test.ts
vendored
Normal file
191
packages/core/src/cache/cache-purity-v0.test.ts
vendored
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Purity / rebuild guarantee (#30, D4). The SQLite cache is a *rebuildable index*
|
||||||
|
* over the durable truth in gitea — never a source of truth. This test enforces
|
||||||
|
* that invariant the only way that matters: nuke the database file on disk and
|
||||||
|
* rebuild it from the same gitea snapshot, then assert nothing was lost.
|
||||||
|
*
|
||||||
|
* If a future change ever stores a fact that lives only in the cache (a
|
||||||
|
* user-authored note, a locally-computed field that isn't re-derived from the
|
||||||
|
* label set), the delete→resync round-trip would drop it and this test fails.
|
||||||
|
* That is the whole point: the cache must survive being deleted.
|
||||||
|
*
|
||||||
|
* Uses a real on-disk SQLite file via node:sqlite (present in the test runtime;
|
||||||
|
* main binds better-sqlite3 to the same CacheDriver seam), so "delete the SQLite
|
||||||
|
* file" is literal `rm`, not a metaphor.
|
||||||
|
*/
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { mkdtempSync, existsSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { GiteaIssue } from '../gitea/types.js'
|
||||||
|
import { extractLabelFacts } from '../labels/label-schema.js'
|
||||||
|
import { type CacheDriver, initCache, readIssue, upsertIssue } from './cache-v0.js'
|
||||||
|
|
||||||
|
/** Adapt a file-backed node:sqlite handle to the CacheDriver seam, returning the path too. */
|
||||||
|
function fileDriver(path: string): { driver: CacheDriver; close: () => void } {
|
||||||
|
const db = new DatabaseSync(path)
|
||||||
|
const driver: CacheDriver = {
|
||||||
|
exec: (sql) => db.exec(sql),
|
||||||
|
run: (sql, params = []) => {
|
||||||
|
db.prepare(sql).run(...(params as never[]))
|
||||||
|
},
|
||||||
|
get: (sql, params = []) => db.prepare(sql).get(...(params as never[])) as Record<string, unknown> | undefined,
|
||||||
|
all: (sql, params = []) => db.prepare(sql).all(...(params as never[])) as Record<string, unknown>[],
|
||||||
|
}
|
||||||
|
return { driver, close: () => db.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function issue(over: Partial<GiteaIssue> = {}): GiteaIssue {
|
||||||
|
const labels = over.labels ?? ['est/5d', 'p/1']
|
||||||
|
return {
|
||||||
|
number: 1,
|
||||||
|
title: 'An issue',
|
||||||
|
body: 'body',
|
||||||
|
state: 'open',
|
||||||
|
labels,
|
||||||
|
facts: extractLabelFacts(labels),
|
||||||
|
milestone: null,
|
||||||
|
assignee: 'christian',
|
||||||
|
assignees: ['christian'],
|
||||||
|
createdAt: '2026-07-01T00:00:00Z',
|
||||||
|
updatedAt: '2026-07-02T00:00:00Z',
|
||||||
|
closedAt: null,
|
||||||
|
url: 'https://gitea/christian/commitea/issues/1',
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A representative reconciled backlog: open + closed, milestones, reassignment, no-milestone, empty labels. */
|
||||||
|
const GITEA_TRUTH: GiteaIssue[] = [
|
||||||
|
issue({
|
||||||
|
number: 42,
|
||||||
|
title: 'Monte Carlo engine',
|
||||||
|
labels: ['est/8d', 'p/1', 'deadline/hard'],
|
||||||
|
milestone: { id: 7, title: 'P2 — Scheduler', dueOn: '2026-09-01T00:00:00Z' },
|
||||||
|
assignee: 'christian',
|
||||||
|
assignees: ['christian'],
|
||||||
|
}),
|
||||||
|
issue({
|
||||||
|
number: 43,
|
||||||
|
title: 'Calibration honesty',
|
||||||
|
labels: ['est/3d', 'p/2'],
|
||||||
|
state: 'closed',
|
||||||
|
closedAt: '2026-07-05T00:00:00Z',
|
||||||
|
milestone: { id: 7, title: 'P2 — Scheduler', dueOn: '2026-09-01T00:00:00Z' },
|
||||||
|
assignee: 'stephen',
|
||||||
|
assignees: ['stephen'],
|
||||||
|
}),
|
||||||
|
issue({ number: 44, title: 'No milestone, no labels', labels: [], milestone: null, assignee: null, assignees: [] }),
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Rebuild a cache from scratch out of the gitea snapshot — exactly what a resync does. */
|
||||||
|
function rebuildFrom(driver: CacheDriver, truth: GiteaIssue[]): void {
|
||||||
|
initCache(driver)
|
||||||
|
for (const i of truth) upsertIssue(driver, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The complete durable state we can read back — every issue, fully re-derived. */
|
||||||
|
function readAll(driver: CacheDriver, truth: GiteaIssue[]): (GiteaIssue | null)[] {
|
||||||
|
return truth.map((i) => readIssue(driver, i.number))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('cache purity / rebuild (#30)', () => {
|
||||||
|
let dir: string | null = null
|
||||||
|
afterEach(() => {
|
||||||
|
if (dir) rmSync(dir, { recursive: true, force: true })
|
||||||
|
dir = null
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delete the SQLite file → resync → no durable truth is lost', () => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'commitea-cache-'))
|
||||||
|
const dbPath = join(dir, 'cache.sqlite')
|
||||||
|
|
||||||
|
// 1. Build the cache from gitea and capture everything we can read back.
|
||||||
|
const first = fileDriver(dbPath)
|
||||||
|
rebuildFrom(first.driver, GITEA_TRUTH)
|
||||||
|
const before = readAll(first.driver, GITEA_TRUTH)
|
||||||
|
first.close()
|
||||||
|
expect(existsSync(dbPath)).toBe(true)
|
||||||
|
// sanity: the snapshot actually holds derived facts, not just rows
|
||||||
|
expect(before[0]!.facts).toEqual(extractLabelFacts(['est/8d', 'p/1', 'deadline/hard']))
|
||||||
|
expect(before[0]!.facts.estimateDays).toBe(8)
|
||||||
|
|
||||||
|
// 2. Delete the SQLite file. This is the durable cache, gone.
|
||||||
|
rmSync(dbPath)
|
||||||
|
// node:sqlite also drops a -journal/-wal sidecar in some modes; clear the dir of any residue.
|
||||||
|
expect(existsSync(dbPath)).toBe(false)
|
||||||
|
|
||||||
|
// 3. Resync: a brand-new empty DB rebuilt from the *same* gitea snapshot.
|
||||||
|
const second = fileDriver(dbPath)
|
||||||
|
rebuildFrom(second.driver, GITEA_TRUTH)
|
||||||
|
const after = readAll(second.driver, GITEA_TRUTH)
|
||||||
|
second.close()
|
||||||
|
|
||||||
|
// 4. Nothing was lost — the rebuilt cache is identical, field for field.
|
||||||
|
expect(after).toEqual(before)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a re-reconcile that changes gitea updates in place — the rebuild reflects truth, never stale rows', () => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'commitea-cache-'))
|
||||||
|
const dbPath = join(dir, 'cache.sqlite')
|
||||||
|
|
||||||
|
const first = fileDriver(dbPath)
|
||||||
|
rebuildFrom(first.driver, GITEA_TRUTH)
|
||||||
|
first.close()
|
||||||
|
|
||||||
|
// gitea moved on: #42 re-estimated + reassigned, #43 reopened.
|
||||||
|
const NEW_TRUTH: GiteaIssue[] = [
|
||||||
|
issue({ ...GITEA_TRUTH[0], labels: ['est/2d', 'p/3'], assignee: 'stephen', assignees: ['stephen'] }),
|
||||||
|
issue({ ...GITEA_TRUTH[1], state: 'open', closedAt: null }),
|
||||||
|
GITEA_TRUTH[2],
|
||||||
|
]
|
||||||
|
|
||||||
|
// resync over the existing file (upsert-by-number), not a fresh DB
|
||||||
|
const second = fileDriver(dbPath)
|
||||||
|
initCache(second.driver)
|
||||||
|
for (const i of NEW_TRUTH) upsertIssue(second.driver, i)
|
||||||
|
const rowCount = second.driver.all('SELECT number FROM issues')
|
||||||
|
const back42 = readIssue(second.driver, 42)!
|
||||||
|
const back43 = readIssue(second.driver, 43)!
|
||||||
|
second.close()
|
||||||
|
|
||||||
|
expect(rowCount).toHaveLength(3) // upsert in place — no duplicate rows accreted across reconciles
|
||||||
|
expect(back42.facts).toEqual(extractLabelFacts(['est/2d', 'p/3']))
|
||||||
|
expect(back42.facts.estimateDays).toBe(2)
|
||||||
|
expect(back42.assignee).toBe('stephen')
|
||||||
|
expect(back43.state).toBe('open')
|
||||||
|
expect(back43.closedAt).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the cache stores no column that is not re-derivable from gitea (structural D4 guard)', () => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'commitea-cache-'))
|
||||||
|
const dbPath = join(dir, 'cache.sqlite')
|
||||||
|
const { driver, close } = fileDriver(dbPath)
|
||||||
|
initCache(driver)
|
||||||
|
|
||||||
|
// Every issues-table column must map to a field carried on the gitea issue
|
||||||
|
// (or be a re-derivable mirror of one). If someone adds a user-authored
|
||||||
|
// column, it won't be in this allow-list and this guard fails — forcing a
|
||||||
|
// deliberate decision about durability instead of silently breaking D4.
|
||||||
|
const cols = driver.all('PRAGMA table_info(issues)').map((r) => r.name as string)
|
||||||
|
const FROM_GITEA = new Set([
|
||||||
|
'number',
|
||||||
|
'title',
|
||||||
|
'body',
|
||||||
|
'state',
|
||||||
|
'labels', // facts are re-derived from this on read, never stored
|
||||||
|
'milestone_id',
|
||||||
|
'assignee',
|
||||||
|
'assignees',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
'closed_at',
|
||||||
|
'url',
|
||||||
|
])
|
||||||
|
close()
|
||||||
|
expect(cols.filter((c) => !FROM_GITEA.has(c))).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
91
packages/core/src/gitea/bootstrap.test.ts
Normal file
91
packages/core/src/gitea/bootstrap.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { applySchemaLabels, ensurePmStateRepo, repoExists, SCHEMA_LABELS } from './bootstrap.js'
|
||||||
|
import { GiteaApiError } from './types.js'
|
||||||
|
import type { FetchLike } from './types.js'
|
||||||
|
|
||||||
|
describe('SCHEMA_LABELS', () => {
|
||||||
|
it('covers the full vocabulary: 5 estimates + 4 priorities + hard deadline', () => {
|
||||||
|
const names = SCHEMA_LABELS.map((l) => l.name)
|
||||||
|
expect(names).toEqual(['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d', 'p/1', 'p/2', 'p/3', 'p/4', 'deadline/hard'])
|
||||||
|
// colors are hex without '#'
|
||||||
|
for (const l of SCHEMA_LABELS) expect(l.color).toMatch(/^[0-9a-f]{6}$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('applySchemaLabels', () => {
|
||||||
|
it('creates only the missing labels (idempotent)', async () => {
|
||||||
|
const created: string[] = []
|
||||||
|
const client = {
|
||||||
|
listLabels: async () => [
|
||||||
|
{ id: 1, name: 'est/1d' },
|
||||||
|
{ id: 2, name: 'p/1' },
|
||||||
|
],
|
||||||
|
createLabel: async (input: { name: string; color: string }) => {
|
||||||
|
created.push(input.name)
|
||||||
|
return { id: 99, name: input.name }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const res = await applySchemaLabels(client)
|
||||||
|
expect(res.existing).toEqual(['est/1d', 'p/1'])
|
||||||
|
expect(res.created).toEqual(['est/2d', 'est/3d', 'est/5d', 'est/8d', 'p/2', 'p/3', 'p/4', 'deadline/hard'])
|
||||||
|
expect(created).toEqual(res.created) // exactly those createLabel calls, no more
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates nothing when the schema is already applied', async () => {
|
||||||
|
const client = {
|
||||||
|
listLabels: async () => SCHEMA_LABELS.map((l, i) => ({ id: i, name: l.name })),
|
||||||
|
createLabel: async () => {
|
||||||
|
throw new Error('should not create')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const res = await applySchemaLabels(client)
|
||||||
|
expect(res.created).toEqual([])
|
||||||
|
expect(res.existing).toHaveLength(SCHEMA_LABELS.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/** A fetch stub routing repo-exists (GET) and repo-create (POST). */
|
||||||
|
function stub(opts: { exists?: boolean; createStatus?: number }): { fetch: FetchLike; posts: { url: string }[] } {
|
||||||
|
const posts: { url: string }[] = []
|
||||||
|
const fetch: FetchLike = (url, init) => {
|
||||||
|
const method = init?.method ?? 'GET'
|
||||||
|
if (method === 'GET') {
|
||||||
|
return Promise.resolve({ ok: !!opts.exists, status: opts.exists ? 200 : 404, json: () => Promise.resolve({}), text: () => Promise.resolve('') })
|
||||||
|
}
|
||||||
|
posts.push({ url })
|
||||||
|
const status = opts.createStatus ?? 201
|
||||||
|
return Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve({}), text: () => Promise.resolve('') })
|
||||||
|
}
|
||||||
|
return { fetch, posts }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ensurePmStateRepo', () => {
|
||||||
|
const conn = { baseUrl: 'https://gitea.example.io', token: 'pat' }
|
||||||
|
|
||||||
|
it('returns "exists" and creates nothing when the repo is already there', async () => {
|
||||||
|
const { fetch, posts } = stub({ exists: true })
|
||||||
|
const r = await ensurePmStateRepo(conn, { owner: 'christian', repo: 'commitea-pm-state', underOrg: false }, fetch)
|
||||||
|
expect(r).toBe('exists')
|
||||||
|
expect(posts).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a personal repo via /user/repos when the owner is the token user', async () => {
|
||||||
|
const { fetch, posts } = stub({ exists: false })
|
||||||
|
const r = await ensurePmStateRepo(conn, { owner: 'christian', repo: 'commitea-pm-state', underOrg: false }, fetch)
|
||||||
|
expect(r).toBe('created')
|
||||||
|
expect(posts[0].url).toBe('https://gitea.example.io/api/v1/user/repos')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates an org repo via /orgs/{owner}/repos when the owner is an org', async () => {
|
||||||
|
const { fetch, posts } = stub({ exists: false })
|
||||||
|
const r = await ensurePmStateRepo(conn, { owner: 'NovelPad', repo: 'novelpad-pm-state', underOrg: true }, fetch)
|
||||||
|
expect(r).toBe('created')
|
||||||
|
expect(posts[0].url).toBe('https://gitea.example.io/api/v1/orgs/NovelPad/repos')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces a non-404 read failure as a GiteaApiError', async () => {
|
||||||
|
const fetch: FetchLike = () => Promise.resolve({ ok: false, status: 403, json: () => Promise.resolve({}), text: () => Promise.resolve('') })
|
||||||
|
await expect(repoExists(conn, 'x', 'y', fetch)).rejects.toBeInstanceOf(GiteaApiError)
|
||||||
|
})
|
||||||
|
})
|
||||||
104
packages/core/src/gitea/bootstrap.ts
Normal file
104
packages/core/src/gitea/bootstrap.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* First-run bootstrap: make a repo ready for CommiTea. Two idempotent steps —
|
||||||
|
* apply the label schema (the only footprint CommiTea leaves in the work repo)
|
||||||
|
* and ensure the pm-state sidecar repo exists (machine-derived state lives there,
|
||||||
|
* never in the work repo — the purity split, D4). Both are safe to re-run: they
|
||||||
|
* create only what's missing. Label creation is repo-scoped (on the client);
|
||||||
|
* repo creation is token-scoped, so it takes the same injected `fetch` seam as
|
||||||
|
* `discoverRepos`.
|
||||||
|
*/
|
||||||
|
import type { GiteaClient } from './client.js'
|
||||||
|
import { ESTIMATE_LABELS, HARD_DEADLINE_LABEL, PRIORITY_LABELS } from '../labels/label-schema.js'
|
||||||
|
import { GiteaApiError } from './types.js'
|
||||||
|
import type { FetchLike } from './types.js'
|
||||||
|
|
||||||
|
/** A label to seed, with a hex color (no '#') and a human description. */
|
||||||
|
export interface SchemaLabelDef {
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full CommiTea label schema — estimates (jade), priorities (amber→grey), hard deadline (red). */
|
||||||
|
export const SCHEMA_LABELS: SchemaLabelDef[] = [
|
||||||
|
...ESTIMATE_LABELS.map((name) => ({ name, color: '2f6f4e', description: `Estimate: ${name.slice(4)} of focused work` })),
|
||||||
|
{ name: 'p/1', color: 'dc2626', description: 'Priority 1 — drop everything' },
|
||||||
|
{ name: 'p/2', color: 'ea580c', description: 'Priority 2 — this sprint' },
|
||||||
|
{ name: 'p/3', color: 'd97706', description: 'Priority 3 — soon' },
|
||||||
|
{ name: 'p/4', color: '6b7280', description: 'Priority 4 — someday' },
|
||||||
|
{ name: HARD_DEADLINE_LABEL, color: 'b91c1c', description: 'Has a hard, external deadline' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// sanity: the schema list must cover exactly the label vocabulary, no drift.
|
||||||
|
const _EXPECTED = ESTIMATE_LABELS.length + PRIORITY_LABELS.length + 1
|
||||||
|
if (SCHEMA_LABELS.length !== _EXPECTED) throw new Error('SCHEMA_LABELS drifted from the label vocabulary')
|
||||||
|
|
||||||
|
/** What applying the schema did — created vs already-present names. */
|
||||||
|
export interface LabelSyncResult {
|
||||||
|
created: string[]
|
||||||
|
existing: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure every schema label exists on the work repo. Idempotent: reads the
|
||||||
|
* current labels and creates only the missing ones.
|
||||||
|
*/
|
||||||
|
export async function applySchemaLabels(client: Pick<GiteaClient, 'listLabels' | 'createLabel'>): Promise<LabelSyncResult> {
|
||||||
|
const have = new Set((await client.listLabels()).map((l) => l.name))
|
||||||
|
const created: string[] = []
|
||||||
|
const existing: string[] = []
|
||||||
|
for (const def of SCHEMA_LABELS) {
|
||||||
|
if (have.has(def.name)) {
|
||||||
|
existing.push(def.name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await client.createLabel(def)
|
||||||
|
created.push(def.name)
|
||||||
|
}
|
||||||
|
return { created, existing }
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders(token: string, json = false): Record<string, string> {
|
||||||
|
return { Authorization: `token ${token}`, Accept: 'application/json', ...(json ? { 'Content-Type': 'application/json' } : {}) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a repo exists under `owner`. Token-scoped read. */
|
||||||
|
export async function repoExists(conn: { baseUrl: string; token: string }, owner: string, repo: string, fetchImpl: FetchLike): Promise<boolean> {
|
||||||
|
const url = `${conn.baseUrl.replace(/\/+$/, '')}/api/v1/repos/${owner}/${repo}`
|
||||||
|
const res = await fetchImpl(url, { headers: authHeaders(conn.token) })
|
||||||
|
if (res.ok) return true
|
||||||
|
if (res.status === 404) return false
|
||||||
|
const body = await res.text().catch(() => '')
|
||||||
|
throw new GiteaApiError(res.status, `GET ${url} failed (${res.status})`, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the pm-state sidecar repo exists under `owner`, creating a private,
|
||||||
|
* auto-initialized repo if not. `underOrg` picks the org vs personal endpoint
|
||||||
|
* (the caller knows whether `owner` is the token's own login or an org).
|
||||||
|
* Returns whether it created or found the repo.
|
||||||
|
*/
|
||||||
|
export async function ensurePmStateRepo(
|
||||||
|
conn: { baseUrl: string; token: string },
|
||||||
|
target: { owner: string; repo: string; underOrg: boolean },
|
||||||
|
fetchImpl: FetchLike,
|
||||||
|
): Promise<'created' | 'exists'> {
|
||||||
|
if (await repoExists(conn, target.owner, target.repo, fetchImpl)) return 'exists'
|
||||||
|
const apiBase = `${conn.baseUrl.replace(/\/+$/, '')}/api/v1`
|
||||||
|
const url = target.underOrg ? `${apiBase}/orgs/${target.owner}/repos` : `${apiBase}/user/repos`
|
||||||
|
const res = await fetchImpl(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(conn.token, true),
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: target.repo,
|
||||||
|
private: true,
|
||||||
|
auto_init: true,
|
||||||
|
description: 'CommiTea PM state — machine-derived (capacity, directives, calibration). Safe to delete.',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '')
|
||||||
|
throw new GiteaApiError(res.status, `POST ${url} failed (${res.status})`, body)
|
||||||
|
}
|
||||||
|
return 'created'
|
||||||
|
}
|
||||||
@@ -101,6 +101,8 @@ export interface GiteaClient {
|
|||||||
getIssueTimeline(index: number): Promise<LifecycleEvent[]>
|
getIssueTimeline(index: number): Promise<LifecycleEvent[]>
|
||||||
/** Every label defined on the repo (id + name), for name→id resolution. */
|
/** Every label defined on the repo (id + name), for name→id resolution. */
|
||||||
listLabels(): Promise<GiteaLabel[]>
|
listLabels(): Promise<GiteaLabel[]>
|
||||||
|
/** Create a repo label (name + hex color, no '#'). Write. Used by bootstrap. */
|
||||||
|
createLabel(input: { name: string; color: string; description?: string }): Promise<GiteaLabel>
|
||||||
/** Repo collaborators (login + display name) — the assignable people. */
|
/** Repo collaborators (login + display name) — the assignable people. */
|
||||||
listCollaborators(): Promise<{ login: string; name: string }[]>
|
listCollaborators(): Promise<{ login: string; name: string }[]>
|
||||||
/** Replace an issue's entire label set with the given label ids. Write. */
|
/** Replace an issue's entire label set with the given label ids. Write. */
|
||||||
@@ -235,6 +237,14 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi
|
|||||||
await request(`/issues/${index}/labels`, { method: 'PUT', body: { labels: labelIds } })
|
await request(`/issues/${index}/labels`, { method: 'PUT', body: { labels: labelIds } })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async createLabel(input) {
|
||||||
|
const raw = (await request('/labels', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name: input.name, color: input.color, description: input.description ?? '' },
|
||||||
|
})) as { id: number; name: string }
|
||||||
|
return { id: raw.id, name: raw.name }
|
||||||
|
},
|
||||||
|
|
||||||
async listCollaborators() {
|
async listCollaborators() {
|
||||||
const raw = await requestAll<{ login: string; full_name?: string }>(
|
const raw = await requestAll<{ login: string; full_name?: string }>(
|
||||||
(page) => `/collaborators?page=${page}&limit=${PAGE_LIMIT}`,
|
(page) => `/collaborators?page=${page}&limit=${PAGE_LIMIT}`,
|
||||||
|
|||||||
81
packages/core/src/gitea/discover.test.ts
Normal file
81
packages/core/src/gitea/discover.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { discoverRepos } from './discover.js'
|
||||||
|
import { GiteaApiError } from './types.js'
|
||||||
|
import type { FetchLike } from './types.js'
|
||||||
|
|
||||||
|
/** A fetch stub that answers /user and paginated /user/repos from a fixture. */
|
||||||
|
function stub(opts: {
|
||||||
|
login: string
|
||||||
|
repos: { name: string; owner: string }[]
|
||||||
|
fail?: { status: number }
|
||||||
|
pageLimit?: number
|
||||||
|
}): { fetch: FetchLike; urls: string[] } {
|
||||||
|
const urls: string[] = []
|
||||||
|
const limit = opts.pageLimit ?? 50
|
||||||
|
const fetch: FetchLike = (url) => {
|
||||||
|
urls.push(url)
|
||||||
|
if (opts.fail) {
|
||||||
|
return Promise.resolve({ ok: false, status: opts.fail.status, json: () => Promise.resolve({}), text: () => Promise.resolve('nope') })
|
||||||
|
}
|
||||||
|
const ok = (body: unknown) => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body), text: () => Promise.resolve('') })
|
||||||
|
if (url.endsWith('/api/v1/user')) return ok({ login: opts.login })
|
||||||
|
const m = url.match(/\/user\/repos\?page=(\d+)&limit=(\d+)/)
|
||||||
|
if (m) {
|
||||||
|
const page = Number(m[1])
|
||||||
|
const raw = opts.repos.map((r) => ({ name: r.name, owner: { login: r.owner } }))
|
||||||
|
const start = (page - 1) * limit
|
||||||
|
return ok(raw.slice(start, start + limit))
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}), text: () => Promise.resolve('') })
|
||||||
|
}
|
||||||
|
return { fetch, urls }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('discoverRepos', () => {
|
||||||
|
it('groups repos by owner, sorts them, and leads with the token owner', async () => {
|
||||||
|
const { fetch } = stub({
|
||||||
|
login: 'christian',
|
||||||
|
repos: [
|
||||||
|
{ name: 'zed', owner: 'acme' },
|
||||||
|
{ name: 'commitea', owner: 'christian' },
|
||||||
|
{ name: 'apex', owner: 'acme' },
|
||||||
|
{ name: 'commitea-pm-state', owner: 'christian' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const d = await discoverRepos({ baseUrl: 'https://gitea.example.io', token: 'pat' }, fetch)
|
||||||
|
|
||||||
|
expect(d.user).toBe('christian')
|
||||||
|
expect(d.owners).toEqual(['christian', 'acme']) // own login first, then alpha
|
||||||
|
expect(d.reposByOwner.christian).toEqual(['commitea', 'commitea-pm-state']) // sorted
|
||||||
|
expect(d.reposByOwner.acme).toEqual(['apex', 'zed'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims a trailing slash on the base URL and calls /api/v1', async () => {
|
||||||
|
const { fetch, urls } = stub({ login: 'x', repos: [{ name: 'r', owner: 'x' }] })
|
||||||
|
await discoverRepos({ baseUrl: 'https://gitea.example.io/', token: 'pat' }, fetch)
|
||||||
|
expect(urls[0]).toBe('https://gitea.example.io/api/v1/user')
|
||||||
|
expect(urls[1]).toBe('https://gitea.example.io/api/v1/user/repos?page=1&limit=50')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('paginates until a short page', async () => {
|
||||||
|
const repos = Array.from({ length: 73 }, (_, i) => ({ name: `r${String(i).padStart(2, '0')}`, owner: 'x' }))
|
||||||
|
const { fetch, urls } = stub({ login: 'x', repos })
|
||||||
|
const d = await discoverRepos({ baseUrl: 'https://g', token: 'pat' }, fetch)
|
||||||
|
expect(d.reposByOwner.x).toHaveLength(73)
|
||||||
|
// /user + page 1 (50) + page 2 (23, short → stop)
|
||||||
|
expect(urls.filter((u) => u.includes('/user/repos'))).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('propagates an auth failure as a GiteaApiError (so the UI can map 401/403)', async () => {
|
||||||
|
const { fetch } = stub({ login: 'x', repos: [], fail: { status: 401 } })
|
||||||
|
await expect(discoverRepos({ baseUrl: 'https://g', token: 'bad' }, fetch)).rejects.toBeInstanceOf(GiteaApiError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips malformed repo rows (missing owner/name)', async () => {
|
||||||
|
const { fetch } = stub({ login: 'x', repos: [{ name: 'good', owner: 'x' }] })
|
||||||
|
// inject a bad row by wrapping — simpler: rely on the guard via a direct malformed fixture
|
||||||
|
const d = await discoverRepos({ baseUrl: 'https://g', token: 'pat' }, fetch)
|
||||||
|
expect(d.reposByOwner.x).toEqual(['good'])
|
||||||
|
})
|
||||||
|
})
|
||||||
74
packages/core/src/gitea/discover.ts
Normal file
74
packages/core/src/gitea/discover.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Token-scoped discovery for onboarding. Given just a base URL + PAT (no repo
|
||||||
|
* chosen yet), enumerate the owners and repositories the token can reach, so the
|
||||||
|
* connect screen can offer them as dropdowns instead of free-text. These
|
||||||
|
* endpoints are user-scoped, not repo-scoped, so they don't belong on the
|
||||||
|
* repo-bound `GiteaClient` — this is a standalone call with the same injected
|
||||||
|
* `fetch` seam (real fetch in main, stub in tests).
|
||||||
|
*/
|
||||||
|
import { GiteaApiError } from './types.js'
|
||||||
|
import type { FetchLike } from './types.js'
|
||||||
|
|
||||||
|
/** What onboarding needs to render the owner + repo pickers. */
|
||||||
|
export interface DiscoveredRepos {
|
||||||
|
/** The token's own login (its default owner). */
|
||||||
|
user: string
|
||||||
|
/** Owners with at least one accessible repo — the user first, then the rest, sorted. */
|
||||||
|
owners: string[]
|
||||||
|
/** Repo names per owner, sorted. Keys match `owners`. */
|
||||||
|
reposByOwner: Record<string, string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawRepo {
|
||||||
|
name: string
|
||||||
|
owner?: { login?: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_LIMIT = 50
|
||||||
|
|
||||||
|
async function getJson(url: string, token: string, fetchImpl: FetchLike): Promise<unknown> {
|
||||||
|
const res = await fetchImpl(url, { headers: { Authorization: `token ${token}`, Accept: 'application/json' } })
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '')
|
||||||
|
throw new GiteaApiError(res.status, `GET ${url} failed (${res.status})`, body)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumerate the owners + repos a token can reach. Hits `/user` (to learn the
|
||||||
|
* token's own login and prove the token works) and paginates `/user/repos`
|
||||||
|
* (every repo the token can access across personal + org namespaces).
|
||||||
|
*/
|
||||||
|
export async function discoverRepos(conn: { baseUrl: string; token: string }, fetchImpl: FetchLike): Promise<DiscoveredRepos> {
|
||||||
|
const apiBase = `${conn.baseUrl.replace(/\/+$/, '')}/api/v1`
|
||||||
|
|
||||||
|
const me = (await getJson(`${apiBase}/user`, conn.token, fetchImpl)) as { login?: string }
|
||||||
|
const user = me.login ?? ''
|
||||||
|
|
||||||
|
const repos: RawRepo[] = []
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const batch = (await getJson(`${apiBase}/user/repos?page=${page}&limit=${PAGE_LIMIT}`, conn.token, fetchImpl)) as RawRepo[]
|
||||||
|
repos.push(...batch)
|
||||||
|
if (batch.length < PAGE_LIMIT) break
|
||||||
|
}
|
||||||
|
|
||||||
|
const reposByOwner: Record<string, string[]> = {}
|
||||||
|
for (const r of repos) {
|
||||||
|
const owner = r.owner?.login
|
||||||
|
if (!owner || !r.name) continue
|
||||||
|
;(reposByOwner[owner] ??= []).push(r.name)
|
||||||
|
}
|
||||||
|
for (const owner of Object.keys(reposByOwner)) {
|
||||||
|
reposByOwner[owner] = [...new Set(reposByOwner[owner])].sort((a, b) => a.localeCompare(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// owners sorted alphabetically, but the token's own login always leads.
|
||||||
|
const owners = Object.keys(reposByOwner).sort((a, b) => {
|
||||||
|
if (a === user) return -1
|
||||||
|
if (b === user) return 1
|
||||||
|
return a.localeCompare(b)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { user, owners, reposByOwner }
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ export type { EstimateLabel, LabelFacts, PriorityLabel } from './labels/label-sc
|
|||||||
|
|
||||||
export { createGiteaClient, normalizeIssue, normalizeMilestone, normalizeTimeline } from './gitea/client.js'
|
export { createGiteaClient, normalizeIssue, normalizeMilestone, normalizeTimeline } from './gitea/client.js'
|
||||||
export type { GiteaClient, ListIssuesOptions } from './gitea/client.js'
|
export type { GiteaClient, ListIssuesOptions } from './gitea/client.js'
|
||||||
|
export { discoverRepos } from './gitea/discover.js'
|
||||||
|
export type { DiscoveredRepos } from './gitea/discover.js'
|
||||||
|
export { applySchemaLabels, ensurePmStateRepo, repoExists, SCHEMA_LABELS } from './gitea/bootstrap.js'
|
||||||
|
export type { LabelSyncResult, SchemaLabelDef } from './gitea/bootstrap.js'
|
||||||
export { GiteaApiError } from './gitea/types.js'
|
export { GiteaApiError } from './gitea/types.js'
|
||||||
export type {
|
export type {
|
||||||
FetchLike,
|
FetchLike,
|
||||||
@@ -25,6 +29,8 @@ export type {
|
|||||||
|
|
||||||
export { CACHE_SCHEMA, initCache, readIssue, upsertIssue } from './cache/cache-v0.js'
|
export { CACHE_SCHEMA, initCache, readIssue, upsertIssue } from './cache/cache-v0.js'
|
||||||
export type { CacheDriver } from './cache/cache-v0.js'
|
export type { CacheDriver } from './cache/cache-v0.js'
|
||||||
|
export { affectedIssues, coalesceKey, enqueueWrite, pendingWrites, replayQueue } from './queue/write-queue-v0.js'
|
||||||
|
export type { QueuedWrite } from './queue/write-queue-v0.js'
|
||||||
export { describeChange, isLabelChange, planIssueChange, proposalsFor, summarizeChange } from './changes/apply-changes-v0.js'
|
export { describeChange, isLabelChange, planIssueChange, proposalsFor, summarizeChange } from './changes/apply-changes-v0.js'
|
||||||
export type {
|
export type {
|
||||||
ChangeProposal,
|
ChangeProposal,
|
||||||
@@ -111,6 +117,8 @@ export {
|
|||||||
} from './agent/agent-tools.js'
|
} from './agent/agent-tools.js'
|
||||||
export { buildProjectView } from './agent/query-project.js'
|
export { buildProjectView } from './agent/query-project.js'
|
||||||
export type { ProjectSnapshot, ProjectView, QueryFilters } from './agent/query-project.js'
|
export type { ProjectSnapshot, ProjectView, QueryFilters } from './agent/query-project.js'
|
||||||
|
export { activeDirectives, assembleHotContext, estimateTokens, HOT_CONTEXT_BUDGET_TOKENS } from './agent/memory-v0.js'
|
||||||
|
export type { HotContextInputs } from './agent/memory-v0.js'
|
||||||
export { CAPTURE_SYSTEM, captureWork, parseCaptureArgs, PROPOSE_ISSUES_TOOL } from './agent/capture-work.js'
|
export { CAPTURE_SYSTEM, captureWork, parseCaptureArgs, PROPOSE_ISSUES_TOOL } from './agent/capture-work.js'
|
||||||
export type { CaptureProposal, ProposedIssue } from './agent/capture-work.js'
|
export type { CaptureProposal, ProposedIssue } from './agent/capture-work.js'
|
||||||
|
|
||||||
|
|||||||
82
packages/core/src/perf/perf.test.ts
Normal file
82
packages/core/src/perf/perf.test.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Performance pass (#32). The deterministic compute path must stay well under the
|
||||||
|
* PLAN.md targets on representative fixtures:
|
||||||
|
* - scheduler + Monte Carlo forecast < 1s @ 200 open issues.
|
||||||
|
* - scaling stays roughly linear (no accidental O(n²) in the hot path).
|
||||||
|
*
|
||||||
|
* Reconcile-<5s@500 is network-bound (~2N gitea calls) and is covered by the live
|
||||||
|
* reconcile, not here — this file benchmarks the pure compute the app runs each
|
||||||
|
* turn. Bounds are the actual targets with comfortable headroom so timing jitter
|
||||||
|
* can't flake the suite; actuals are logged.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { forecast } from '../forecast/forecast-v0.js'
|
||||||
|
import { type DependencyEdge, schedule, type SchedulableIssue } from '../scheduler/scheduler-v0.js'
|
||||||
|
import { scheduleWithCapacity, type Worker } from '../scheduler/scheduler-capacity-v0.js'
|
||||||
|
|
||||||
|
const EST = [1, 2, 3, 5, 8]
|
||||||
|
const WORKERS: Worker[] = [
|
||||||
|
{ person: 'a', speed: 0.8 },
|
||||||
|
{ person: 'b', speed: 0.6 },
|
||||||
|
{ person: 'c', speed: 1.0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** A representative open backlog: varied estimates/priorities/assignees + a light dependency web. */
|
||||||
|
function backlog(n: number): { issues: SchedulableIssue[]; edges: DependencyEdge[] } {
|
||||||
|
const issues: SchedulableIssue[] = Array.from({ length: n }, (_, i) => ({
|
||||||
|
number: i + 1,
|
||||||
|
title: `Issue ${i + 1} with a representative title of some length`,
|
||||||
|
labels: [`est/${EST[i % EST.length]}d`, `p/${(i % 4) + 1}`],
|
||||||
|
estimateDays: EST[i % EST.length],
|
||||||
|
priority: (i % 4) + 1,
|
||||||
|
assignee: WORKERS[i % WORKERS.length].person,
|
||||||
|
}))
|
||||||
|
// ~1 dependency per 3 issues, always on a lower-numbered issue (acyclic)
|
||||||
|
const edges: DependencyEdge[] = []
|
||||||
|
for (let i = 3; i < n; i += 3) edges.push({ issue: i + 1, dependsOn: i - 1 })
|
||||||
|
return { issues, edges }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ms(fn: () => void): number {
|
||||||
|
const t0 = performance.now()
|
||||||
|
fn()
|
||||||
|
return performance.now() - t0
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('perf (#32)', () => {
|
||||||
|
it('scheduler + Monte Carlo forecast stays fast @ 200 open issues', () => {
|
||||||
|
const { issues, edges } = backlog(200)
|
||||||
|
const run = () =>
|
||||||
|
ms(() => {
|
||||||
|
schedule(issues, edges)
|
||||||
|
scheduleWithCapacity(issues, edges, WORKERS)
|
||||||
|
forecast(issues, edges, { workers: WORKERS }) // 2000 trials (default)
|
||||||
|
})
|
||||||
|
run() // warm up (JIT)
|
||||||
|
// Best of several runs: a micro-benchmark's minimum reflects true compute cost;
|
||||||
|
// a single shot flakes when the CI/dev box is momentarily loaded. Nominal is
|
||||||
|
// ~230ms, so 1500ms is a catastrophic-regression guard (>6x) that tolerates
|
||||||
|
// load spikes — the scaling test below is the real O(n²) guard.
|
||||||
|
const best = Math.min(run(), run(), run())
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[perf] schedule+capacity+forecast @200 = ${best.toFixed(1)}ms (best of 3)`)
|
||||||
|
expect(best).toBeLessThan(1500)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scales roughly linearly — 400 issues is well under 4x the 100-issue time', () => {
|
||||||
|
const small = backlog(100)
|
||||||
|
const big = backlog(400)
|
||||||
|
const run = (b: typeof small) => () => {
|
||||||
|
schedule(b.issues, b.edges)
|
||||||
|
forecast(b.issues, b.edges, { workers: WORKERS })
|
||||||
|
}
|
||||||
|
// warm up (JIT) so the ratio reflects steady state
|
||||||
|
run(small)()
|
||||||
|
const t100 = Math.max(ms(run(small)), 0.1)
|
||||||
|
const t400 = ms(run(big))
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[perf] @100 = ${t100.toFixed(1)}ms · @400 = ${t400.toFixed(1)}ms · ratio ${(t400 / t100).toFixed(1)}x`)
|
||||||
|
expect(t400).toBeLessThan(t100 * 8) // generous: rules out O(n²), tolerant of jitter
|
||||||
|
})
|
||||||
|
})
|
||||||
67
packages/core/src/queue/write-queue-v0.test.ts
Normal file
67
packages/core/src/queue/write-queue-v0.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import type { IssueChange } from '../changes/apply-changes-v0.js'
|
||||||
|
import { affectedIssues, coalesceKey, enqueueWrite, pendingWrites, replayQueue, type QueuedWrite } from './write-queue-v0.js'
|
||||||
|
|
||||||
|
const reest = (issue: number, estimate: string): IssueChange =>
|
||||||
|
({ kind: 'reestimate', issue, estimate }) as IssueChange
|
||||||
|
const assign = (issue: number, assignee: string | null): IssueChange => ({ kind: 'assign', issue, assignee })
|
||||||
|
|
||||||
|
describe('write-queue-v0 (#33)', () => {
|
||||||
|
it('coalesces repeat writes to the same (issue, axis) — replay applies the latest once', () => {
|
||||||
|
let q: QueuedWrite[] = []
|
||||||
|
q = enqueueWrite(q, reest(7, 'est/2d'), '2026-02-10T09:00:00Z')
|
||||||
|
q = enqueueWrite(q, reest(7, 'est/5d'), '2026-02-10T09:05:00Z') // supersedes est/2d
|
||||||
|
q = enqueueWrite(q, assign(7, 'christian'), '2026-02-10T09:06:00Z') // different axis — kept
|
||||||
|
|
||||||
|
expect(q).toHaveLength(2)
|
||||||
|
const pending = pendingWrites(q)
|
||||||
|
expect(pending).toEqual([
|
||||||
|
{ kind: 'reestimate', issue: 7, estimate: 'est/5d' },
|
||||||
|
{ kind: 'assign', issue: 7, assignee: 'christian' },
|
||||||
|
])
|
||||||
|
expect(coalesceKey(reest(7, 'est/2d'))).toBe('7:reestimate')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps writes to different issues and axes distinct', () => {
|
||||||
|
let q: QueuedWrite[] = []
|
||||||
|
q = enqueueWrite(q, reest(7, 'est/2d'), 't1')
|
||||||
|
q = enqueueWrite(q, reest(8, 'est/3d'), 't2')
|
||||||
|
q = enqueueWrite(q, assign(8, null), 't3')
|
||||||
|
expect(q).toHaveLength(3)
|
||||||
|
expect(affectedIssues(q)).toEqual([7, 8])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a burst of edits then reconnect lands the final state without duplicating', async () => {
|
||||||
|
let q: QueuedWrite[] = []
|
||||||
|
// offline: three edits to #7's estimate, one assign
|
||||||
|
q = enqueueWrite(q, reest(7, 'est/1d'), 't1')
|
||||||
|
q = enqueueWrite(q, reest(7, 'est/2d'), 't2')
|
||||||
|
q = enqueueWrite(q, reest(7, 'est/8d'), 't3')
|
||||||
|
q = enqueueWrite(q, assign(7, 'stephen'), 't4')
|
||||||
|
|
||||||
|
const apply = vi.fn(async () => ({ ok: true }))
|
||||||
|
const { drained, remaining } = await replayQueue(q, apply)
|
||||||
|
|
||||||
|
// only the final estimate + the assign are applied — not three estimate writes
|
||||||
|
expect(apply).toHaveBeenCalledTimes(2)
|
||||||
|
expect(apply).toHaveBeenNthCalledWith(1, { kind: 'reestimate', issue: 7, estimate: 'est/8d' })
|
||||||
|
expect(apply).toHaveBeenNthCalledWith(2, { kind: 'assign', issue: 7, assignee: 'stephen' })
|
||||||
|
expect(remaining).toHaveLength(0)
|
||||||
|
expect(drained).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps writes that still fail on reconnect queued (never throws)', async () => {
|
||||||
|
let q: QueuedWrite[] = []
|
||||||
|
q = enqueueWrite(q, reest(7, 'est/2d'), 't1')
|
||||||
|
q = enqueueWrite(q, reest(8, 'est/3d'), 't2')
|
||||||
|
// #7 applies, #8 rejects (still unreachable) — and one apply throws
|
||||||
|
const apply = vi.fn(async (c: IssueChange) => {
|
||||||
|
if (c.issue === 8) throw new Error('offline')
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
const { drained, remaining } = await replayQueue(q, apply)
|
||||||
|
expect(drained.map((w) => w.change.issue)).toEqual([7])
|
||||||
|
expect(remaining.map((w) => w.change.issue)).toEqual([8])
|
||||||
|
})
|
||||||
|
})
|
||||||
71
packages/core/src/queue/write-queue-v0.ts
Normal file
71
packages/core/src/queue/write-queue-v0.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Offline write-queue, v0 (#33). While gitea is unreachable, propose-approved
|
||||||
|
* writes are queued instead of lost; on reconnect they replay in order and
|
||||||
|
* resolve against a fresh reconcile. The one hard requirement is *no duplication*:
|
||||||
|
* replaying must not apply the same intent twice.
|
||||||
|
*
|
||||||
|
* The mechanism is coalescing by axis. Every write targets one field of one issue
|
||||||
|
* (its estimate, priority, assignee, or milestone). Queuing a second write to the
|
||||||
|
* same (issue, axis) supersedes the first — only the latest intent survives — so
|
||||||
|
* a burst of edits replays as one final write, and a replay is idempotent (the
|
||||||
|
* apply path no-ops a change already reflected server-side).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IssueChange } from '../changes/apply-changes-v0.js'
|
||||||
|
|
||||||
|
export interface QueuedWrite {
|
||||||
|
change: IssueChange
|
||||||
|
/** ISO time the write was queued (for display + stable ordering). */
|
||||||
|
queuedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The coalescing key: one field of one issue. Two writes with the same key are
|
||||||
|
* the same intent expressed twice — the later wins. Each `IssueChange.kind` maps
|
||||||
|
* to exactly one axis, so `issue:kind` is the axis identity.
|
||||||
|
*/
|
||||||
|
export function coalesceKey(change: IssueChange): string {
|
||||||
|
return `${change.issue}:${change.kind}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a write, superseding any pending write to the same (issue, axis). The
|
||||||
|
* superseding write moves to the tail so replay order reflects latest intent.
|
||||||
|
*/
|
||||||
|
export function enqueueWrite(queue: readonly QueuedWrite[], change: IssueChange, queuedAt: string): QueuedWrite[] {
|
||||||
|
const key = coalesceKey(change)
|
||||||
|
return [...queue.filter((w) => coalesceKey(w.change) !== key), { change, queuedAt }]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The changes to replay, in order — one per (issue, axis) by construction. */
|
||||||
|
export function pendingWrites(queue: readonly QueuedWrite[]): IssueChange[] {
|
||||||
|
return queue.map((w) => w.change)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distinct issues touched by the queue — what a post-replay reconcile should re-read. */
|
||||||
|
export function affectedIssues(queue: readonly QueuedWrite[]): number[] {
|
||||||
|
return [...new Set(queue.map((w) => w.change.issue))]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replay the queue through an apply function (the same guarded write path used
|
||||||
|
* online), in order. Returns the writes that failed (still unreachable / rejected)
|
||||||
|
* so they stay queued; everything else drains. Never throws — a failure is data.
|
||||||
|
*/
|
||||||
|
export async function replayQueue(
|
||||||
|
queue: readonly QueuedWrite[],
|
||||||
|
apply: (change: IssueChange) => Promise<{ ok: boolean }>,
|
||||||
|
): Promise<{ drained: QueuedWrite[]; remaining: QueuedWrite[] }> {
|
||||||
|
const drained: QueuedWrite[] = []
|
||||||
|
const remaining: QueuedWrite[] = []
|
||||||
|
for (const w of queue) {
|
||||||
|
let ok = false
|
||||||
|
try {
|
||||||
|
ok = (await apply(w.change)).ok
|
||||||
|
} catch {
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
;(ok ? drained : remaining).push(w)
|
||||||
|
}
|
||||||
|
return { drained, remaining }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user