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/1326.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Unified the task and delivery boards into one "Board" dialog. The Tasks/Delivery tab split is removed; both task cards and GitHub cards now appear together in a single view.
283 changes: 229 additions & 54 deletions core/client.js

Large diffs are not rendered by default.

238 changes: 201 additions & 37 deletions core/src/client/delivery-view.js

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ const ZH = {
'{from} → {to}': '{from} → {to}',
'+{count} more': '另有 {count} 条',
delegated: '委托转达',
All: '全部',
Filter: '筛选',
Review: '查看',
'Attached files': '附件',
Retry: '重试',
Expand Down Expand Up @@ -682,7 +684,9 @@ const ZH = {
'Could not comment on {target}: {error}': '无法在 {target} 上留言:{error}',
'Could not confirm the comment on {target} went out': '无法确认 {target} 上的留言是否发出',
// Bot task board (#1058)
Board: '看板',
Tasks: '任务',
'Close board': '关闭看板',
'Close tasks': '关闭任务',
'Task board': '任务板',
'Not started': '未开始',
Expand Down
51 changes: 36 additions & 15 deletions core/src/client/kanban.js
Original file line number Diff line number Diff line change
Expand Up @@ -358,10 +358,36 @@ function scopeName(roster, scope) {
return ''
}

// #1326: one board that shows both task cards and delivery cards together, no tab split.
// Columns come from workflow definitions; task cards map into the same columns as delivery cards.
function UnifiedBoard({ roster, tasks, rail, scope, counts, dragging, error, draft, actions, onDraft, onSelect, onMove, onDrag, onCreate }) {
const selectedBot = scope?.kind === 'bot' ? scope.botId : ''
const selectedRoom = scope?.kind === 'group' ? scope.roomId : ''
const lanes = scope?.kind === 'group'
return h('div', { className: 'bt-board-body' },
h('aside', { className: 'bt-board-rail', 'aria-label': t('Task board') },
h('h3', { className: 'bt-board-rail-head' }, t('Your Bots')),
rail.bots.length === 0 ? h('p', { className: 'bt-board-empty' }, t('No tasks yet.')) : rail.bots.map(row => {
const count = counts?.[row.id] ?? { done: 0, total: 0 }
return h(RailButton, {
key: row.id, row, selected: row.id === selectedBot, onSelect,
meta: count.total > 0 ? t('{done}/{total}', { done: count.done, total: count.total }) : '',
})
}),
rail.groups.length > 0 ? h('h3', { className: 'bt-board-rail-head' }, t('Groups you admin')) : null,
rail.groups.map(row => h(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect }))),
h('div', { className: 'bt-board-main' },
scope?.kind === 'bot' && onCreate ? h('form', { className: 'bt-board-add', onSubmit: (event) => { event.preventDefault(); onCreate(draft) } },
h('input', { 'aria-label': t('Task title'), placeholder: t('New task'), value: draft ?? '', onChange: (event) => onDraft?.(event.target.value) }),
h('button', { type: 'submit', className: 'bt-soft bt-soft-sm' }, t('Add task'))) : null,
error ? h('p', { className: 'bt-board-error', role: 'alert' }, error) : null,
lanes ? h('p', { className: 'bt-board-hint' }, t('Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.')) : null,
h(DeliveryView, { actions, tasks, roster, dragging, onMove, onDrag })))
}

export function KanbanDialog({ roster, actions, leaving = false }) {
// Two views on one surface: the per-Bot task board (#1058) and the repository's delivery
// board (#1173). Delivery is repo-wide, so its rail would be a lie — it is not rendered.
const [view, setView] = useState('tasks')
// One board (#1326): task cards and delivery cards together, no tab split. The rail is per-Bot
// for task scope; delivery is repo-wide and reads from actions.deliveryBoard.
const rail = boardRail(roster)
const [scope, setScope] = useState(() => (rail.bots[0] ? { kind: 'bot', botId: rail.bots[0].id } : rail.groups[0] ? { kind: 'group', roomId: rail.groups[0].id } : null))
const [dragging, setDragging] = useState(null)
Expand Down Expand Up @@ -405,19 +431,14 @@ export function KanbanDialog({ roster, actions, leaving = false }) {
h('div', { className: 'bt-scrim', onClick: close }),
h('div', { className: 'bt-mem bt-board', role: 'dialog', 'aria-modal': true, 'aria-labelledby': 'bt-board-heading' },
h('div', { className: 'bt-mem-head' },
h('h2', { id: 'bt-board-heading' }, view === 'delivery' ? t('Delivery') : t('Tasks')),
h('div', { className: 'bt-board-views', role: 'tablist', 'aria-label': t('Board view') },
h('button', { type: 'button', role: 'tab', className: 'bt-soft bt-soft-sm', 'data-view': 'tasks', 'aria-selected': view === 'tasks' ? 'true' : 'false', onClick: () => setView('tasks') }, t('Tasks')),
h('button', { type: 'button', role: 'tab', className: 'bt-soft bt-soft-sm', 'data-view': 'delivery', 'aria-selected': view === 'delivery' ? 'true' : 'false', onClick: () => setView('delivery') }, t('Delivery'))),
view === 'tasks' ? h('span', { className: 'bt-mem-status' }, name) : null,
h('h2', { id: 'bt-board-heading' }, t('Board')),
h('span', { className: 'bt-mem-status' }, name),
h('span', { className: 'bt-mem-head-gap' }),
h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close tasks'), autoFocus: true, onClick: close }, h(CloseIcon))),
view === 'delivery'
? h(DeliveryView, { actions })
: h(BoardView, {
roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft,
onDraft: setDraft, onSelect: setScope, onMove: move, onDrag: setDragging, onCreate: create,
})))
h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close board'), autoFocus: true, onClick: close }, h(CloseIcon))),
h(UnifiedBoard, {
roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft, actions,
onDraft: setDraft, onSelect: setScope, onMove: move, onDrag: setDragging, onCreate: create,
})))
}

export const KANBAN_CSS = `
Expand Down
34 changes: 23 additions & 11 deletions core/src/host/delivery-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { repoSlug } from './update.js'
import { NO_REPO_MESSAGE, instanceRepoSettings, resolveRepos } from './delivery-repo.js'
import { boardSections } from '../shared/workflow-board.js'

export const DELIVERY_COLUMNS = ['raw', 'ready', 'working', 'review', 'done-today']
// #1326: Workflow-based columns. The three visible stages of repo-dev; review and done-today are filters.
export const DELIVERY_COLUMNS = ['not-started', 'in-progress', 'done']

// State labels first on a card, then the areas; the order is the order the board shows.
const STATE_LABELS = ['needs-human', 'needs-info', 'agent-ready', 'agent-working', 'agent-pr', 'awaiting-approval', 'hold']
Expand Down Expand Up @@ -145,12 +146,14 @@ export function cardOf(row, { kind, linked = null, owner = null, badge = null, s
}
}

// #1326: Map delivery stage to workflow columns. PRs are in-progress (build/verify/gate);
// review is a filter (column:false). Issues map by labels.
export function columnOfDelivery(row, { kind }) {
const labels = labelNames(row)
if (kind === 'pr') return 'review'
if (labels.includes('agent-working')) return 'working'
if (labels.includes('agent-ready')) return 'ready'
return 'raw'
if (kind === 'pr') return 'in-progress'
if (labels.includes('agent-working') || labels.includes('agent-pr')) return 'in-progress'
if (labels.includes('agent-ready')) return 'not-started'
return 'not-started'
}

// Which part of the board a path belongs to. The view turns this into advice a person can act on
Expand Down Expand Up @@ -543,7 +546,8 @@ export function createDeliveryBoard({ repo, repoSource = null, token, request =
let mergedToday = []
let todayMetrics = null
let weekMetrics = null
const columns = { raw: [], ready: [], working: [], review: [], 'done-today': [] }
// #1326: Three workflow columns; done-today cards go in 'done' with data-stage='done-today' for filtering.
const columns = { 'not-started': [], 'in-progress': [], 'done': [] }
try {
// The two open lists are the board itself: when they cannot be read there is no board to
// show, so that failure is the round's, not a part's (#1211).
Expand Down Expand Up @@ -620,7 +624,13 @@ export function createDeliveryBoard({ repo, repoSource = null, token, request =
if (error?.budget) { note('budget', error); break }
note(`comments:${pr.number}`, error)
}
columns.review.push({ ...cardOf(pr, { kind: 'pr', owner, ownerKind, badge, sha: String(pr.head?.sha ?? '').slice(0, 7) }), badgeBasis: basis })
// An open pull request is work in progress, and `review` is a stage a card carries: the
// definition draws no column for it (#1326), so the card is placed by `columnOfDelivery`.
columns[columnOfDelivery(pr, { kind: 'pr' })].push({
...cardOf(pr, { kind: 'pr', owner, ownerKind, badge, sha: String(pr.head?.sha ?? '').slice(0, 7) }),
badgeBasis: basis,
stage: 'review',
})
}
const closedInDay = (row) => Date.parse(row.closed_at ?? '') >= today
const doneIssues = closedToday.filter(row => !isPull(row) && closedInDay(row))
Expand All @@ -632,10 +642,12 @@ export function createDeliveryBoard({ repo, repoSource = null, token, request =
// settle it even when that row carries no merge time.
const mergedNumbers = new Set(donePulls.map(row => row.number))
const abandonedPulls = closedToday.filter(row => isPull(row) && !row.pull_request?.merged_at && !mergedNumbers.has(row.number) && closedInDay(row))
for (const row of donePulls) columns['done-today'].push(cardOf(row, { kind: 'pr', sha: String(row.merge_commit_sha ?? '').slice(0, 7) || null }))
for (const row of abandonedPulls) columns['done-today'].push(cardOf(row, { kind: 'pr' }))
for (const row of doneIssues) columns['done-today'].push(cardOf(row, { kind: 'issue' }))
columns['done-today'] = columns['done-today'].slice(0, MAX_DONE)
// #1326: done-today cards go in 'done' column with stage='done-today' for filtering.
for (const row of donePulls) columns.done.push({ ...cardOf(row, { kind: 'pr', sha: String(row.merge_commit_sha ?? '').slice(0, 7) || null }), stage: 'done-today' })
for (const row of abandonedPulls) columns.done.push({ ...cardOf(row, { kind: 'pr' }), stage: 'done-today' })
for (const row of doneIssues) columns.done.push({ ...cardOf(row, { kind: 'issue' }), stage: 'done-today' })
// done column holds all done cards; done-today is a filter, not a separate column.
columns.done = columns.done.slice(0, MAX_DONE)
const needsHuman = issues.filter(row => labelNames(row).includes('needs-human') || labelNames(row).includes('needs-info')).length
const openPrs = pullRows.length
return {
Expand Down
Loading
Loading