diff --git a/apps/desktop/e2e/live-backlog.spec.ts b/apps/desktop/e2e/live-backlog.spec.ts
index 4362ea2..b610aac 100644
--- a/apps/desktop/e2e/live-backlog.spec.ts
+++ b/apps/desktop/e2e/live-backlog.spec.ts
@@ -11,6 +11,7 @@ const MAIN = join(here, '..', 'out', 'main', 'index.js')
test.describe('live backlog', () => {
test('The pot + Focus render real gitea data', async () => {
test.skip(!process.env.GITEA_LIVE, 'GITEA_LIVE not set — opt-in live test')
+ test.setTimeout(90_000)
const app = await electron.launch({ args: [MAIN], env: { ...process.env } })
const win = await app.firstWindow()
await win.waitForLoadState('domcontentloaded')
@@ -42,16 +43,17 @@ test.describe('live backlog', () => {
win.getByText(/cold-start priors · \d+\/20 closed issues estimated|calibrated on \d+ closed/),
).toBeVisible()
// Real per-milestone forecasts — these milestone names come from gitea, not the
- // fixture (which lists Beta / Pilot-ready / v1.0).
- await expect(win.getByText(/P2 — Scheduler/)).toBeVisible()
+ // fixture (Beta / Pilot-ready / v1.0). The runway shows milestones with open
+ // scope; P5 is active (P2 is fully shipped, so it's correctly omitted).
+ await expect(win.getByText(/P5 — Dogfood/)).toBeVisible()
// Real capacity config from pm-state (christian/stephen), not the fixture (Stephen/Ana K.)
await expect(win.getByText('christian', { exact: true })).toBeVisible()
await expect(win.getByText(/pd\/day/).first()).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-runway.png'), fullPage: true, animations: 'disabled' })
- // Milestone drill-in — clicking a real milestone opens its real detail
- await win.getByText(/P2 — Scheduler/).click()
- await expect(win.getByRole('heading', { name: 'P2 — Scheduler + Monte Carlo' })).toBeVisible()
+ // Milestone drill-in — clicking a real (active) milestone opens its real detail
+ await win.getByText(/P5 — Dogfood/).click()
+ await expect(win.getByRole('heading', { name: 'P5 — Dogfood + polish' })).toBeVisible()
await expect(win.getByText(/\d+ issues · est \d+d/)).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-milestone.png'), fullPage: true, animations: 'disabled' })
await rail.getByRole('button', { name: 'Runway' }).click()
@@ -68,10 +70,15 @@ test.describe('live backlog', () => {
await win.getByRole('main').getByRole('link').first().click()
await expect(win.getByText(/· stephen\/commitea/)).toBeVisible()
await win.getByRole('button', { name: 'Adjust' }).click()
- await expect(win.getByText('Adjust estimate & priority')).toBeVisible()
+ await expect(win.getByText('Adjust issue')).toBeVisible()
await win.getByRole('combobox').first().selectOption('est/8d')
- await expect(win.getByText('Proposed label change')).toBeVisible()
+ await expect(win.getByText('Proposed change')).toBeVisible()
await expect(win.getByText(/est\/8d/).last()).toBeVisible()
+ // the unified tool also drives assignee + milestone — the pickers render and diff
+ await expect(win.getByText('Assignee', { exact: true })).toBeVisible()
+ await expect(win.getByText('Milestone', { exact: true })).toBeVisible()
+ await win.getByRole('combobox').nth(2).selectOption('') // Assignee → Unassigned
+ await expect(win.getByText('unassign', { exact: true })).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-apply-change.png'), fullPage: true, animations: 'disabled' })
await win.getByRole('button', { name: 'Cancel' }).click() // no mutation
diff --git a/apps/desktop/src/main/gitea.ts b/apps/desktop/src/main/gitea.ts
index 8334d54..1bafee0 100644
--- a/apps/desktop/src/main/gitea.ts
+++ b/apps/desktop/src/main/gitea.ts
@@ -19,6 +19,7 @@ import {
type GiteaConfig,
type GiteaLabel,
type IssueChange,
+ isLabelChange,
type LifecycleEvent,
makeDirectiveEntry,
parseCapacityConfig,
@@ -263,19 +264,32 @@ export function registerGiteaIpc(): void {
return client.getIssue(index)
})
- // The write path (apply_changes). Additive label swaps, applied only after the
- // renderer's propose-approve. Returns the plan + the freshly-read issue.
+ // The write path (apply_changes), applied only after the renderer's propose-
+ // approve. Label swaps (est/p) return the plan; field writes (assign, milestone)
+ // return the freshly-read issue directly. Either way the snapshot is invalidated
+ // so the board + forecast reflect the change.
ipcMain.handle('gitea:applyChange', async (_event, change: IssueChange) => {
const client = getGiteaClient()
if (!client) return { ok: false as const, reason: 'unconfigured' as const }
- const current = await client.getIssue(change.issue)
- const plan = planIssueChange(current.labels, change)
- if (plan.noop) return { ok: true as const, plan, issue: current }
- const ids = await resolveLabelIds(client, plan.labels)
- await client.setIssueLabels(change.issue, ids)
- const issue = await client.getIssue(change.issue)
- invalidateSnapshot() // the board + forecast must reflect the label change
- return { ok: true as const, plan, issue }
+
+ if (isLabelChange(change)) {
+ const current = await client.getIssue(change.issue)
+ const plan = planIssueChange(current.labels, change)
+ if (plan.noop) return { ok: true as const, plan, issue: current }
+ const ids = await resolveLabelIds(client, plan.labels)
+ await client.setIssueLabels(change.issue, ids)
+ const issue = await client.getIssue(change.issue)
+ invalidateSnapshot()
+ return { ok: true as const, plan, issue }
+ }
+
+ // Field writes — the client returns the updated issue.
+ const issue =
+ change.kind === 'assign'
+ ? await client.setIssueAssignees(change.issue, change.assignee ? [change.assignee] : [])
+ : await client.setIssueMilestone(change.issue, change.milestone)
+ invalidateSnapshot()
+ return { ok: true as const, issue }
})
// capture_work filing: open each approved issue with its est/* + p/* labels.
@@ -319,6 +333,17 @@ export function registerGiteaIpc(): void {
}
})
+ // Assignable people (repo collaborators) for the Adjust dialog's assignee picker.
+ ipcMain.handle('gitea:collaborators', async () => {
+ const client = getGiteaClient()
+ if (!client) return []
+ try {
+ return await client.listCollaborators()
+ } catch {
+ return []
+ }
+ })
+
// ---- config (team onboarding) ----
ipcMain.handle('config:get', () => publicConfig())
diff --git a/apps/desktop/src/main/model.ts b/apps/desktop/src/main/model.ts
index a4c7711..7883448 100644
--- a/apps/desktop/src/main/model.ts
+++ b/apps/desktop/src/main/model.ts
@@ -13,7 +13,6 @@ import {
type ChangeProposal,
type ChatMessage,
createChatClient,
- describeChange,
type ModelRouter,
type ProjectView,
proposalsFor,
@@ -130,10 +129,19 @@ export function registerModelIpc(): void {
const a = (args ?? {}) as ProposeChangeArgs
const issue = await client.getIssue(a.issue).catch(() => null)
if (!issue) return { error: `issue #${a.issue} not found` }
- const built = proposalsFor(a, issue.labels, issue.title)
+ // Milestone titles + current assignee/milestone let a proposal label the
+ // milestone and skip a no-op assign/move.
+ const milestones = (a.milestone !== undefined ? await client.listMilestones().catch(() => []) : []).map(
+ (m) => ({ id: m.id, title: m.title }),
+ )
+ const built = proposalsFor(a, issue.labels, issue.title, {
+ currentAssignee: issue.assignee,
+ currentMilestoneId: issue.milestone?.id ?? null,
+ milestones,
+ })
proposals.push(...built)
return built.length
- ? { proposed: built.map((p) => ({ issue: a.issue, diff: describeChange(p.plan) })) }
+ ? { proposed: built.map((p) => ({ issue: a.issue, diff: p.summary })) }
: { proposed: [], note: 'no change — already at that value' }
}
if (name === 'record_directive') {
diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts
index cd8c2c9..b780fee 100644
--- a/apps/desktop/src/preload/index.ts
+++ b/apps/desktop/src/preload/index.ts
@@ -15,6 +15,8 @@ const api = {
applyChange: (change: unknown) => ipcRenderer.invoke('gitea:applyChange', change),
/** File a set of captured issues with their est/* + p/* labels. */
createIssues: (issues: unknown) => ipcRenderer.invoke('gitea:createIssues', issues),
+ /** Assignable people (repo collaborators) for the assignee picker. */
+ collaborators: () => ipcRenderer.invoke('gitea:collaborators'),
},
pmstate: {
/** Read the directive ledger from the pm-state repo. */
diff --git a/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx b/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx
index 7ab09b6..b1e2685 100644
--- a/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx
+++ b/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx
@@ -2,13 +2,12 @@
import React, { useState } from 'react'
import {
- describeChange,
type EstimateLabel,
ESTIMATE_LABELS,
type IssueChange,
- planIssueChange,
type PriorityLabel,
PRIORITY_LABELS,
+ summarizeChange,
} from '@commitea/core'
import { ISSUE_DETAIL, type IssueDetail, type IssueRef } from '../../data/fixtures.js'
@@ -23,6 +22,10 @@ export function IssueScreen({
onOpenIssue,
canWrite = false,
onApplyChange,
+ collaborators = [],
+ milestones = [],
+ currentAssignee = null,
+ currentMilestoneId = null,
}: {
issue: IssueRef
detail?: IssueDetail
@@ -30,6 +33,10 @@ export function IssueScreen({
onOpenIssue: (issue: IssueRef) => void
canWrite?: boolean
onApplyChange?: (change: IssueChange) => Promise<{ ok: boolean }>
+ collaborators?: { login: string; name: string }[]
+ milestones?: { id: number; title: string }[]
+ currentAssignee?: string | null
+ currentMilestoneId?: number | null
}) {
const det: IssueDetail = detail ?? ISSUE_DETAIL[issue.id] ?? {
state: 'triage',
@@ -62,25 +69,40 @@ export function IssueScreen({
const labels = issue.labels ?? []
const curEstimate = labels.find((l) => l.startsWith('est/')) ?? ''
const curPriority = labels.find((l) => l.startsWith('p/')) ?? ''
+ const curAssignee = currentAssignee ?? ''
+ const curMilestone = currentMilestoneId != null ? String(currentMilestoneId) : ''
const [adjustOpen, setAdjustOpen] = useState(false)
const [estimate, setEstimate] = useState(curEstimate)
const [priority, setPriority] = useState(curPriority)
+ const [assignee, setAssignee] = useState(curAssignee)
+ const [milestone, setMilestone] = useState(curMilestone)
const [applying, setApplying] = useState(false)
const openAdjust = () => {
setEstimate(curEstimate)
setPriority(curPriority)
+ setAssignee(curAssignee)
+ setMilestone(curMilestone)
setAdjustOpen(true)
}
- // the concrete changes this dialog would apply, one per axis that differs
+ // the concrete changes this dialog would apply, one per field that differs
const pendingChanges: IssueChange[] = []
if (estimate !== curEstimate)
pendingChanges.push({ kind: 'reestimate', issue: issue.id, estimate: (estimate || null) as EstimateLabel | null })
if (priority !== curPriority)
pendingChanges.push({ kind: 'reprioritize', issue: issue.id, priority: (priority || null) as PriorityLabel | null })
+ if (assignee !== curAssignee)
+ pendingChanges.push({ kind: 'assign', issue: issue.id, assignee: assignee || null })
+ if (milestone !== curMilestone)
+ pendingChanges.push({
+ kind: 'remilestone',
+ issue: issue.id,
+ milestone: milestone ? Number(milestone) : null,
+ milestoneTitle: milestones.find((m) => String(m.id) === milestone)?.title ?? null,
+ })
- const diffs = pendingChanges.map((c) => describeChange(planIssueChange(labels, c)))
+ const diffs = pendingChanges.map((c) => summarizeChange(c, labels))
const apply = async () => {
if (!onApplyChange || pendingChanges.length === 0) return
@@ -95,6 +117,14 @@ export function IssueScreen({
const estOptions = [{ value: '', label: NONE }, ...ESTIMATE_LABELS.map((l) => ({ value: l, label: l }))]
const prioOptions = [{ value: '', label: NONE }, ...PRIORITY_LABELS.map((l) => ({ value: l, label: l }))]
+ const assigneeOptions = [
+ { value: '', label: 'Unassigned' },
+ ...collaborators.map((c) => ({ value: c.login, label: c.name })),
+ ]
+ const milestoneOptions = [
+ { value: '', label: NONE },
+ ...milestones.map((m) => ({ value: String(m.id), label: m.title })),
+ ]
return (
@@ -135,7 +165,7 @@ export function IssueScreen({
+ {(collaborators.length > 0 || milestones.length > 0) && (
+
+ {collaborators.length > 0 ? (
+
+ )}
{pendingChanges.length === 0 ? (
- No change yet — pick a different estimate or priority.
+ No change yet — pick a different estimate, priority, assignee, or milestone.
) : (
<>
- Proposed label change
+ Proposed change
{diffs.map((d) => (
@@ -177,7 +227,7 @@ export function IssueScreen({
))}
- Writes the label to gitea and re-runs the plan. Nothing else changes.
+ Writes to gitea and re-runs the plan. Nothing else changes.
>
)}
diff --git a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx
index 1506d6e..12ab570 100644
--- a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx
+++ b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx
@@ -108,6 +108,7 @@ export function AppShell() {
const [gate, setGate] = useState<'checking' | 'connect' | 'ready'>('checking')
const [demo, setDemo] = useState(false)
const [pubConfig, setPubConfig] = useState
(null)
+ const [collaborators, setCollaborators] = useState<{ login: string; name: string }[]>([])
const [backlog, refetchBacklog] = useBacklog()
const capacityMembers = useCapacity()
const workers = capacityWorkers(capacityMembers)
@@ -189,8 +190,17 @@ export function AppShell() {
})
.catch(() => setGate('connect'))
window.commitea.config.get().then(setPubConfig).catch(() => {})
+ // Assignable people for the Adjust dialog's assignee picker (empty when demo/unconfigured).
+ window.commitea.gitea.collaborators().then(setCollaborators).catch(() => {})
}, [])
+ // The open issue's current assignee + milestone (from the reconciled backlog),
+ // so the Adjust dialog can preselect and diff them. Milestones list feeds its picker.
+ const currentIssue =
+ backlog.status === 'ready' && issue ? backlog.issues.find((i) => i.number === issue.id) : undefined
+ const milestoneOptions =
+ backlog.status === 'ready' ? backlog.milestones.map((m) => ({ id: m.id, title: m.title })) : []
+
const openIssue = (ref: IssueRef) => {
if (view !== 'issue') setPrevView(view)
// Callers into the sidecar (e.g. a "blocks #N" chip) may know only the id.
@@ -343,6 +353,10 @@ export function AppShell() {
onOpenIssue={openIssue}
canWrite={backlog.status === 'ready'}
onApplyChange={applyChange}
+ collaborators={collaborators}
+ milestones={milestoneOptions}
+ currentAssignee={currentIssue?.assignee ?? null}
+ currentMilestoneId={currentIssue?.milestone?.id ?? null}
/>
) : null
case 'states':
diff --git a/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx b/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx
index d396d13..f614251 100644
--- a/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx
+++ b/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
-import { describeChange, type IssueChange } from '@commitea/core'
+import type { IssueChange } from '@commitea/core'
import { useChat } from '../../lib/use-chat.js'
import { Button, Icon, IconButton } from '../ui/index.js'
@@ -127,7 +127,7 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane
Proposed · #{p.change.issue}
- {describeChange(p.plan)}
+ {p.summary}