Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/804-work-page.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Work page now shows a compact state summary with counts and shares, keeps row actions behind one disclosure, moves the new-work-item form into a header dialog and names why ready work is waiting; the work-item table stays the compact list it is.
18 changes: 9 additions & 9 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -843,12 +843,13 @@ becomes a card whose cells carry their column name in a `data-label` attribute
behind a horizontal scroll on a phone, and a new column must emit `data-label`
in `jobRows` to stay readable there. The rest of the data tables reuse the same
block through one class, `table-cards`: the `/admin` users and audit tables, the
dashboard repository table, the repository page's delivery, board and work-item
tables, and the support view's repository and job tables. Adding the class and a
dashboard repository table, the repository page's delivery and work-item tables,
and the support view's repository and job tables. Adding the class and a
`data-label` per cell is the whole change; a table must not grow a second card
mechanism. The delivery board is the one exception, `table-cards table-cards-head`:
its header row is the stage filter, so it stays in the flow and reflows as a
wrapping row of links above the cards instead of disappearing. Above 640px the scroller is also bounded vertically
mechanism. The delivery board is a compact summary of state links with counts
and shares, not a carded table; it wraps above the work-item list and each state
link filters the list. A share needs a denominator: with no work items at all
each state shows `—` rather than a confident `0%`. Above 640px the scroller is also bounded vertically
(`100dvh` minus `--header-h` and `--space-7`, and minus the repository
subnav's `--subnav-h` on a repository page) and its `thead th` is sticky on an
opaque `--panel`, so column names stay above the rows they label; the two rules
Expand All @@ -874,9 +875,8 @@ attribute states, the meta shapes, the 44px control and the two palette tokens
the sticky header and the account pill used to hardcode. Signed-out pages honour
the saved palette but render no control: the chooser rides inside `#main-nav`, so
below 961px the compact header's panel carries it and the phone row gains no
child. Between 641px and 960px a wide table (the
delivery board is 952px wide at 768) can still be wider than its `.table-scroll`
box, so that band draws an always-present classic scrollbar — an explicit track
child. Between 641px and 960px a wide data table can still be wider than its
`.table-scroll` box, so that band draws an always-present classic scrollbar — an explicit track
plus `scrollbar-width`/`scrollbar-color` for Firefox — instead of relying on a
thumb that is 1.5:1 on the panel. The band has its own
`(min-width: 641px) and (max-width: 960px)` block in `public/app.css`; the cards
Expand Down Expand Up @@ -929,7 +929,7 @@ timeline as the token to search, and it is the `job_events.job_id` of that job,
so one search returns the whole job. Pass-level lines (a scheduler pass, the
shutdown check) have no job in scope and deliberately carry no `trace`.

The repository page adds a compact delivery board grouped by work-item state; each state header links to a filtered work-item list (`?workState=`). The dashboard has two scopes. `/dashboard?window=7|30` shows the signed-in
The repository page adds a compact delivery board grouped by work-item state; each state tile links to a filtered work-item list (`?workState=`) and shows its share of the total, or `—` while the board has no items and there is nothing to divide by. The work-item rows keep their actions behind one disclosure, and the create form is a header button and dialog. The dashboard has two scopes. `/dashboard?window=7|30` shows the signed-in
user's own delivery metrics: jobs, success rate, PRs, merged PRs, cost, open
work items and the last job. `/admin/metrics` shows the global window with the
product funnel, delivery efficiency (success and merge rate, median job and
Expand Down
9 changes: 9 additions & 0 deletions public/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,15 @@ p { margin: 0 0 12px; }
inner-id offset, so the two cannot drift apart. */
.repo-page { --subnav-h: calc(var(--touch) + 2 * var(--space-2) + 1px); }
.repo-page [id] { scroll-margin-top: calc(var(--header-h) + var(--subnav-h) + var(--space-4)); }
.work-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 8px; margin: 12px 0 8px; }
.work-summary a { display: grid; gap: 2px; min-width: 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--field); color: var(--text); text-decoration: none; }
.work-summary a:hover, .work-summary a.is-active { border-color: var(--accent); }
.work-summary strong { font-size: var(--text-md); font-variant-numeric: tabular-nums; }
.work-summary span { font-size: var(--text-sm); color: var(--muted); }
.work-summary small { font-size: var(--text-sm); color: var(--muted-2); }
.work-actions summary { cursor: pointer; color: var(--accent); font-size: var(--text-sm); font-weight: 650; }
.work-actions .row { margin-top: 8px; }
@media (max-width: 680px) { .work-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.repo-subnav {
position: sticky; top: var(--header-h); z-index: 15;
display: flex; gap: var(--space-2); margin: 0 0 var(--space-4); min-height: var(--subnav-h);
Expand Down
72 changes: 50 additions & 22 deletions src/views/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1573,37 +1573,65 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer
<td data-label="${escapeHtml(t('优先级', 'Priority'))}">${item.priority}</td>
<td data-label="Issue / PR">${item.issue_url === null ? '' : `<a href="${escapeHtml(item.issue_url)}" target="_blank" rel="noreferrer">Issue</a>`}${item.issue_url !== null && item.pr_url !== null ? ' · ' : ''}${item.pr_url === null ? '' : `<a href="${escapeHtml(item.pr_url)}" target="_blank" rel="noreferrer">PR</a>`}</td>
<td data-label="${escapeHtml(t('任务', 'Job'))}">${item.job_id === null ? '—' : `<a href="/repos/${escapeHtml(repo.id)}/jobs/${escapeHtml(item.job_id)}"><code>${escapeHtml(item.job_id)}</code></a>`}</td>
<td data-label="${escapeHtml(t('操作', 'Actions'))}"><div class="row">
<td data-label="${escapeHtml(t('操作', 'Actions'))}"><details class="work-actions"><summary>${escapeHtml(t('操作', 'Actions'))}</summary><div class="row">
${item.state === 'merged' || item.state === 'closed' ? '' : `<form method="post" action="/repos/${escapeHtml(repo.id)}/work/${escapeHtml(item.id)}/run">${csrf(viewer)}${commandHidden(commands[`work-run:${item.id}`])}<button class="btn btn-small" type="submit">${escapeHtml(t('开始工作', 'Start work'))}</button></form>`}
${item.state === 'merged' || item.state === 'closed' ? '' : `<form method="post" action="/repos/${escapeHtml(repo.id)}/work/${escapeHtml(item.id)}/scope">${csrf(viewer)}${commandHidden(commands[`work-scope:${item.id}`])}<button class="btn btn-ghost btn-small" type="submit">${escapeHtml(t('Product 范围', 'Product scope'))}</button></form>`}
${item.pr_url === null ? '' : `<form method="post" action="/repos/${escapeHtml(repo.id)}/work/${escapeHtml(item.id)}/verify">${csrf(viewer)}${commandHidden(commands[`work-verify:${item.id}`])}<button class="btn btn-ghost btn-small" type="submit">${escapeHtml(t('Test 验证', 'Test verify'))}</button></form>`}
${stateOptions === '' ? '' : `<form method="post" action="/repos/${escapeHtml(repo.id)}/work/${escapeHtml(item.id)}/state" class="row">${csrf(viewer)}<select name="state" aria-label="${escapeHtml(t('新状态', 'New state'))}">${stateOptions}</select><button class="btn btn-ghost btn-small" type="submit">${escapeHtml(t('更新状态', 'Update state'))}</button></form>`}
</div></td>
</div></details></td>
</tr>`
}).join('')
const workCounts = new Map<string, number>()
for (const item of workItems) workCounts.set(item.state, (workCounts.get(item.state) ?? 0) + 1)
const workBoard = `<section class="card" id="board">
<div class="row between"><h2>${escapeHtml(t('交付看板', 'Delivery board'))}</h2><span class="fine">${workState === undefined ? escapeHtml(t('按工作项状态汇总;点击状态可筛选下面的列表。', 'Grouped by state; click a state to filter the list below.')) : `<a class="link" href="/repos/${escapeHtml(repo.id)}#work">${escapeHtml(t('清除筛选', 'Clear filter'))}</a>`}</span></div>
<div class="table-scroll"><table class="table-cards table-cards-head"><thead><tr>${WORK_ITEM_STATES.map((state) => `<th><a class="link" href="/repos/${escapeHtml(repo.id)}?workState=${state}#work">${escapeHtml(workItemStateLabel(t, state))}</a></th>`).join('')}</tr></thead><tbody><tr>${WORK_ITEM_STATES.map((state) => `<td data-label="${escapeHtml(workItemStateLabel(t, state))}"><strong>${workCounts.get(state) ?? 0}</strong></td>`).join('')}</tr></tbody></table></div>
</section>`

const workTotal = workItems.length
const workSummary = `<div class="work-summary" id="board" data-work-summary>${WORK_ITEM_STATES.map((state) => {
const count = workCounts.get(state) ?? 0
// A share needs a denominator. With no work items at all every state would
// otherwise read "0%", which is a confident zero the board cannot support.
const share = workTotal === 0 ? null : Math.round((count / workTotal) * 100)
const active = workState === state
return `<a class="link${active ? ' is-active' : ''}" href="/repos/${escapeHtml(repo.id)}?workState=${state}#work" data-work-state="${state}"><strong>${count}</strong><span>${escapeHtml(workItemStateLabel(t, state))}</span><small>${share === null ? '—' : `${share}%`}</small></a>`
}).join('')}</div>`
const workQueueReason = !repoEnabled
? t('仓库已停用;工作项不会开始。', 'The repository is disabled; work items will not start.')
: !credentialOk
? t('GitHub 凭据失效;工作项不会开始。', 'The GitHub credential is invalid; work items will not start.')
: flybotAvailable && !flybotReady
? t('FlyBot 实例未就绪;工作项不会开始。', 'The FlyBot instance is not ready; work items will not start.')
: repo.auto_iterate !== 1
? t('自动工作已关闭;可以从操作里手动开始。', 'Automatic work is off; start an item manually from its actions.')
: input.activeJob === true
? t('已有任务在运行;队列会在它结束后继续。', 'A job is already running; the queue continues when it finishes.')
: t('工作项会在可运行时开始。', 'Work items start when they are runnable.')
const workCard = `<section class="card">
<div class="row between"><h2>${escapeHtml(t('工作项', 'Work items'))}</h2><span class="fine">${workState === undefined ? escapeHtml(t('Issue / PR / 状态 / 优先级', 'Issue / PR / state / priority')) : escapeHtml(t(`筛选:${workItemStateLabel(t, workState)}`, `Filter: ${workItemStateLabel(t, workState)}`))}</span></div>
<form method="post" action="/repos/${escapeHtml(repo.id)}/work" class="form">
${csrf(viewer)}
<div class="row">
<label class="grow">${escapeHtml(t('标题', 'Title'))} <input name="title" required maxlength="200" /></label>
<label>${escapeHtml(t('优先级', 'Priority'))} <input name="priority" type="number" min="0" max="100" value="0" class="w-90" /></label>
</div>
<div class="row">
<label class="grow">${escapeHtml(t('Issue URL(可选)', 'Issue URL (optional)'))} <input name="issueUrl" placeholder="https://github.com/owner/repo/issues/1" /></label>
<div class="row between">
<div>
<h2>${escapeHtml(t('工作项', 'Work items'))}</h2>
<p class="fine">${workState === undefined ? escapeHtml(t('按状态汇总;点击状态可筛选下面的列表。', 'Grouped by state; click a state to filter the list.')) : `<a class="link" href="/repos/${escapeHtml(repo.id)}#work">${escapeHtml(t('清除筛选', 'Clear filter'))}</a>`}</p>
</div>
<label>${escapeHtml(t('说明', 'Detail'))} <textarea name="detail" rows="3" placeholder="${escapeHtml(t('用户问题、现状、验收标准。', 'User problem, current state, acceptance criteria.'))}"></textarea></label>
<button class="btn" type="submit">${escapeHtml(t('创建工作项', 'Create work item'))}</button>
</form>
<div class="table-scroll mt-14"><table class="table-cards"><thead><tr><th>${escapeHtml(t('工作项', 'Work item'))}</th><th>${escapeHtml(t('状态', 'State'))}</th><th>${escapeHtml(t('优先级', 'Priority'))}</th><th>Issue / PR</th><th>${escapeHtml(t('任务', 'Job'))}</th><th></th></tr></thead><tbody>${workRows}</tbody></table></div>
</section>`;
<button class="btn" type="button" data-open-dialog="new-work-item" aria-haspopup="dialog" aria-controls="new-work-item">${escapeHtml(t('新建工作项', 'New work item'))}</button>
</div>
<div class="row between"><h3 class="mt-16">${escapeHtml(t('交付看板', 'Delivery board'))}</h3><span class="fine">${escapeHtml(t('按状态汇总', 'Grouped by state'))}</span></div>
${workSummary}
<p class="fine" data-work-queue>${escapeHtml(workQueueReason)}</p>
<dialog id="new-work-item" class="add-repo-dialog" aria-labelledby="new-work-item-title">
<div class="row between"><h2 id="new-work-item-title">${escapeHtml(t('新建工作项', 'New work item'))}</h2><button class="link" type="button" data-close-dialog>${escapeHtml(t('关闭', 'Close'))}</button></div>
<form method="post" action="/repos/${escapeHtml(repo.id)}/work" class="form">
${csrf(viewer)}
<div class="row">
<label class="grow">${escapeHtml(t('标题', 'Title'))} <input name="title" required maxlength="200" /></label>
<label>${escapeHtml(t('优先级', 'Priority'))} <input name="priority" type="number" min="0" max="100" value="0" class="w-90" /></label>
</div>
<div class="row">
<label class="grow">${escapeHtml(t('Issue URL(可选)', 'Issue URL (optional)'))} <input name="issueUrl" placeholder="https://github.com/owner/repo/issues/1" /></label>
</div>
<label>${escapeHtml(t('说明', 'Detail'))} <textarea name="detail" rows="3" placeholder="${escapeHtml(t('用户问题、现状、验收标准。', 'User problem, current state, acceptance criteria.'))}"></textarea></label>
<button class="btn" type="submit">${escapeHtml(t('创建工作项', 'Create work item'))}</button>
</form>
</dialog>
<noscript><style>#new-work-item{display:block;position:static;width:auto;max-width:none;max-height:none;margin:12px 0;padding:0;border:0;background:none;box-shadow:none} [data-open-dialog="new-work-item"]{display:none}</style></noscript>
<div class="table-scroll mt-14"><table class="table-cards"><thead><tr><th>${escapeHtml(t('工作项', 'Work item'))}</th><th>${escapeHtml(t('状态', 'State'))}</th><th>${escapeHtml(t('优先级', 'Priority'))}</th><th>Issue / PR</th><th>${escapeHtml(t('任务', 'Job'))}</th><th>${escapeHtml(t('操作', 'Actions'))}</th></tr></thead><tbody>${workRows}</tbody></table></div>
</section>`
const prRows = prJobs.length === 0
? stateCell(7, { kind: 'empty', title: t('没有待处理的 PR。', 'No pull requests waiting.'), body: t('任务产出 PR 后会出现在这里;没有就是真的没有在等。', 'A pull request appears here once a job opens one; an empty list means nothing is waiting.'), action: { href: `/repos/${escapeHtml(repo.id)}`, label: t('回到仓库', 'Back to repository') } })
: prJobs.map((job) => {
Expand Down Expand Up @@ -1852,7 +1880,7 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer
return `<li><code>${escapeHtml(release.tag_name)}</code> <span class="fine">${escapeHtml(release.target_sha.slice(0, 12))} · ${escapeHtml(release.status)}${run}${external}${draft}</span> <form method="post" action="/repos/${escapeHtml(repo.id)}/releases/${escapeHtml(release.id)}/check" class="inline">${csrf(viewer)}<button class="btn btn-ghost" type="submit">${escapeHtml(t('检查工作流', 'Check run'))}</button></form></li>`
}).join('')}</ul>`}
</section>`
const body = `<div class="repo-page">${repoHeader}${repoTabs}${repoSection('overview', `${triageCard}${inboxSection(locale, viewer, questions, `/repos/${repo.id}`)}${chiefCard}${memoryCard}${gateCard}${rulesCard}${securityFindingsCard(t, viewer, repo, securityFindings)}`)}${repoSection('delivery', `${releaseCard}${workflowFailuresCard}${postmortemCard}${reviewTrendCard}${deliveryCard}${jobsCard}`)}${repoSection('work', `${workBoard}${workCard}`)}${repoSection('team', `${roleCard}${instanceCard}`)}${repoSection('settings', settingsCards)}${repoSection('danger', dangerCard)}</div>`
const body = `<div class="repo-page">${repoHeader}${repoTabs}${repoSection('overview', `${triageCard}${inboxSection(locale, viewer, questions, `/repos/${repo.id}`)}${chiefCard}${memoryCard}${gateCard}${rulesCard}${securityFindingsCard(t, viewer, repo, securityFindings)}`)}${repoSection('delivery', `${releaseCard}${workflowFailuresCard}${postmortemCard}${reviewTrendCard}${deliveryCard}${jobsCard}`)}${repoSection('work', `${workCard}`)}${repoSection('team', `${roleCard}${instanceCard}`)}${repoSection('settings', settingsCards)}${repoSection('danger', dangerCard)}</div>`
return layout({ title: `${upstreamOwner}/${upstreamName}`, viewer, body: `${body}${advanced ? '' : `<script src="${assetUrl('/assets/chief.js')}" defer></script>`}<script src="${assetUrl('/assets/repoTabs.js')}" defer></script>`, config, locale, path: `/repos/${repo.id}`, active: 'dashboard', notice, error, notifications: pageMessages })
}

Expand Down
29 changes: 29 additions & 0 deletions test/pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { flashOf, formBody, json, makeTerminalJob, makeUser, MemoryMailer, sessi
import { adminSearchPage, appReposPage, dashboardPage, jobPage, landingPage, metricsPage, repoPage, settingsPage, workspacePage } from '../src/views/pages.js'
import { quotaMoney } from '../src/views/layout.js'
import { localizeMessage } from '../src/i18n.js'
import { WORK_ITEM_STATES } from '../src/lib/workItems.js'
import { encryptSecret } from '../src/lib/secrets.js'

test('healthz reports the package version', async () => {
Expand Down Expand Up @@ -1163,6 +1164,34 @@ test('the repository page shows a delivery board grouped by work-item state', ()
assert.match(page, /Fix the board/)
assert.doesNotMatch(page, /Other work/)
assert.match(page, /Clear filter/)
assert.match(page, /data-open-dialog="new-work-item"/, 'the create form is behind a header button')
assert.match(page, /id="new-work-item"/, 'the create form is a dialog')
assert.match(page, /data-work-queue/, 'the page names why an item is waiting')
assert.match(page, /<details class="work-actions">[\s\S]*name="state"/, 'the row actions are folded into one disclosure')
} finally {
store.close()
}
})

test('an empty delivery board shows a dash, not a confident zero share', () => {
const config = testConfig()
const store = new Store(':memory:')
try {
const user = makeUser(store, { login: 'board-empty', status: 'approved' })
const repo = store.createRepo({ id: 'repo_board_empty', userId: user.id, owner: 'o', name: 'empty-board', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: false, intervalMinutes: 1440 })
const page = repoPage({
config,
locale: 'en',
viewer: { user: store.userById(user.id)!, csrfToken: 'csrf' },
repo,
jobs: [],
workItems: [],
})
const summary = /<div class="work-summary" id="board"[^>]*>([\s\S]*?)<\/div>/.exec(page)?.[1] ?? ''
assert.ok(summary !== '', 'the empty board still renders its state links')
assert.equal((summary.match(/—/g) ?? []).length, WORK_ITEM_STATES.length, 'every state has no denominator, so every state shows a dash')
assert.doesNotMatch(summary, /0%/, 'a board with no items must not print a confident zero share')
assert.doesNotMatch(page, /NaN/)
} finally {
store.close()
}
Expand Down
Loading
Loading