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/1173-delivery-board.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The task board dialog gains a **Delivery** view (#1173): one read-only snapshot of the repository's GitHub issues, pull requests and CI runs, in five columns — original issue, ready to start, in progress, in review, and done today — with a verdict/CI badge per pull request and a header that reports today against the last 7 days. It is repository-wide, so the per-Bot rail does not appear, and it never writes to GitHub: the host serves a cached snapshot built only from `GET` requests, and the token stays host-side. The view replaces the board with one honest line when no GitHub connection is set up.
3,842 changes: 2,054 additions & 1,788 deletions core/client.js

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import * as update from './src/host/update.js'
import * as dshUpdate from './src/host/dsh-update.js'
import * as hooks from './src/host/hooks.js'
import * as remoteChat from './src/host/remote-chat.js'
import * as deliveryBoard from './src/host/delivery-board.js'
import * as channel from './src/host/channel.js'

export { COLORS } from './src/host/constants.js'
Expand All @@ -52,7 +53,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses
// the functions other modules call; they call them through `rt` at run time, never during
// install. Listeners run and tools are listed in registration order, so this order holds:
// usage meters model requests after every listener that rewrites them.
const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, chatIndex, groups, roomMembership, reach, tasks, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, channel]
const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, chatIndex, groups, roomMembership, reach, tasks, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, deliveryBoard, channel]

export function apply(ctx, config = {}) {
const rt = createRuntime(ctx, config)
Expand Down
197 changes: 197 additions & 0 deletions core/src/client/delivery-view.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// Delivery board (#1173): the repository's GitHub issues and pull requests by delivery stage,
// in the same dialog as the per-Bot Tasks board. This is a view only — nothing here writes to
// GitHub, and the snapshot it renders is built host-side (src/host/delivery-board.js).
import { createElement as h, useEffect, useState } from 'react'
import { t } from './i18n.js'

const POLL_MS = 60_000

export const DELIVERY_COLUMNS = [
{ id: 'raw', label: 'Original issue' },
{ id: 'ready', label: 'Ready to start' },
{ id: 'working', label: 'In progress' },
{ id: 'review', label: 'In review' },
{ id: 'done-today', label: 'Done today' },
]

const BADGES = {
inc: 'INC · rework',
'pass-enqueue': 'PASS · waiting for enqueue',
'pass-merge': 'PASS · waiting for merge',
'ci-red': 'CI red',
'ci-running': 'CI running',
'no-verdict': 'No verdict',
}

export const badgeLabel = (badge) => (badge ? t(BADGES[badge] ?? badge) : '')

export function duration(ms) {
if (!Number.isFinite(ms) || ms < 0) return '—'
const hours = ms / 3_600_000
if (hours < 1) return `${Math.max(1, Math.round(ms / 60_000))}m`
if (hours < 48) return `${Math.round(hours)}h`
return `${Math.round(hours / 24)}d`
}

// The header rows the dialog renders; kept pure so the numbers can be checked without React.
export function metricRows(snapshot) {
const today = snapshot?.today ?? {}
const week = snapshot?.week ?? {}
const ci = today.ci ?? {}
const count = (value) => (value === null || value === undefined ? '—' : String(value))
const rows = [
{ label: t('New issues'), key: 'newIssues' },
{ label: t('Closed issues'), key: 'closedIssues' },
{ label: t('New pull requests'), key: 'newPullRequests' },
{ label: t('Merged pull requests'), key: 'mergedPullRequests' },
{ label: t('Median issue close'), key: 'medianIssueCloseMs', format: duration },
{ label: t('Median PR merge'), key: 'medianPullMergeMs', format: duration },
].map(row => {
const format = row.format ?? count
return { label: row.label, today: format(today[row.key]), week: format(week[row.key]) }
})
return {
dayStart: snapshot?.dayStart ?? null,
rows,
ci: {
failed: ci.failed ?? 0,
total: ci.total ?? 0,
counted: ci.counted ?? 0,
caveat: Number.isFinite(ci.total) && Number.isFinite(ci.counted) && ci.counted < ci.total,
superseded: ci.superseded ?? 0,
},
open: snapshot?.open ?? {},
}
}

export function useDeliveryBoard(actions) {
const [snapshot, setSnapshot] = useState(null)
const [message, setMessage] = useState('')
useEffect(() => {
let stopped = false
const load = () => {
const job = Promise.resolve(actions?.deliveryBoard?.())
void job.then((next) => {
if (stopped || !next || typeof next !== 'object') return
if (next.ok === false) {
setMessage(next.reason === 'no-github' ? t('No GitHub connection yet; the board fills in once one is added.') : t('The delivery board is not ready yet.'))
return
}
setSnapshot(next)
setMessage(next.error ? t('Showing the last snapshot from {time}: {error}', { time: timeOf(next.asOf), error: next.error }) : '')
}).catch((error) => { if (!stopped) setMessage(String(error?.message ?? error)) })
}
load()
const timer = setInterval(load, POLL_MS)
return () => { stopped = true; clearInterval(timer) }
}, [actions])
return { snapshot, message }
}

const timeOf = (iso) => {
const at = Date.parse(iso ?? '')
if (!Number.isFinite(at)) return '—'
return new Date(at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}

function Label({ name }) {
return h('span', { className: 'bt-del-label', 'data-label': name }, name)
}

export function DeliveryCard({ card }) {
const badge = badgeLabel(card?.badge)
return h('article', { className: 'bt-del-card', 'data-kind': card?.kind, 'data-number': card?.number, 'data-badge': card?.badge ?? undefined },
h('span', { className: 'bt-del-card-top' },
h('span', { className: 'bt-del-id' }, `#${card?.number ?? '?'}`),
badge ? h('span', { className: 'bt-del-badge', 'data-badge': card?.badge }, badge) : null),
h('span', { className: 'bt-del-title' }, card?.title ?? ''),
card?.labels?.length ? h('span', { className: 'bt-del-labels' }, card.labels.map(name => h(Label, { key: name, name }))) : null,
card?.owner ? h('span', { className: 'bt-del-owner', 'data-kind': card.ownerKind ?? 'human' }, card.owner) : null,
card?.linked ? h('span', { className: 'bt-del-linked' }, t('linked to #{number}', { number: card.linked })) : null,
card?.sha ? h('span', { className: 'bt-del-sha' }, card.sha) : null,
card?.url ? h('a', { className: 'bt-del-open', href: card.url, target: '_blank', rel: 'noreferrer' }, t('Open')) : null)
}

export function DeliveryHeader({ snapshot }) {
if (!snapshot) return null
const { rows, ci, open } = metricRows(snapshot)
return h('header', { className: 'bt-del-head' },
h('div', { className: 'bt-del-metrics' },
h('table', { className: 'bt-del-table' },
h('thead', null, h('tr', null, h('th', null, ''), h('th', null, t('Today')), h('th', null, t('7 days')))),
h('tbody', null, rows.map(row => h('tr', { key: row.label },
h('th', { scope: 'row' }, row.label),
h('td', null, row.today),
h('td', null, row.week))))),
h('p', { className: 'bt-del-ci' }, t('CI failed {failed} of {total}', { failed: ci.failed, total: ci.total }),
ci.caveat ? ` · ${t('counted {counted} of {total}', { counted: ci.counted, total: ci.total })}` : '',
ci.superseded ? ` · ${t('{count} superseded', { count: ci.superseded })}` : ''),
h('p', { className: 'bt-del-open' }, t('Open: {issues} issues ({needs} need a human), {prs} pull requests', {
issues: open.issues ?? 0, needs: open.needsHuman ?? 0, prs: open.pullRequests ?? 0,
}), open.medianIssueAgeMs !== null && open.medianIssueAgeMs !== undefined
? ` · ${t('median open age {age}', { age: duration(open.medianIssueAgeMs) })}` : '')),
h('p', { className: 'bt-del-asof' }, t('as of {time}', { time: timeOf(snapshot.asOf) }),
snapshot.refreshing ? ` · ${t('refreshing…')}` : ''))
}

export function DeliveryBoard({ snapshot, message }) {
if (!snapshot) {
return h('div', { className: 'bt-del-body', 'data-view': 'delivery' },
message
? h('p', { className: 'bt-board-error', role: 'alert' }, message)
: h('p', { className: 'bt-board-empty' }, t('No snapshot yet.')))
}
const columns = snapshot.columns ?? {}
return h('div', { className: 'bt-del-body', 'data-view': 'delivery' },
message ? h('p', { className: 'bt-board-error', role: 'alert' }, message) : null,
h(DeliveryHeader, { snapshot: snapshot.metrics ? { ...snapshot.metrics, asOf: snapshot.asOf, refreshing: snapshot.refreshing } : null }),
h('div', { className: 'bt-del-columns' },
DELIVERY_COLUMNS.map(column => h('section', { key: column.id, className: 'bt-del-column', 'data-column': column.id },
h('h3', { className: 'bt-board-column-head' },
h('span', null, t(column.label)),
h('span', { className: 'bt-board-count' }, String((columns[column.id] ?? []).length))),
(columns[column.id] ?? []).length === 0
? h('p', { className: 'bt-board-empty' }, t('Nothing here.'))
: (columns[column.id] ?? []).map(card => h(DeliveryCard, { key: `${card.kind}-${card.number}`, card }))))))
}

export function DeliveryView({ actions }) {
const { snapshot, message } = useDeliveryBoard(actions)
return h(DeliveryBoard, { snapshot, message })
}

export const DELIVERY_CSS = `
.bt-del-body{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:10px;padding:12px 14px 16px}
.bt-del-head{display:flex;flex-direction:column;gap:6px;border-bottom:.5px solid var(--bt-line);padding-bottom:8px}
.bt-del-metrics{display:flex;flex-wrap:wrap;align-items:flex-start;gap:16px}
.bt-del-table{border-collapse:collapse;font-size:12px;line-height:16px;font-variant-numeric:tabular-nums}
.bt-del-table th,.bt-del-table td{padding:2px 8px;text-align:right;white-space:nowrap}
.bt-del-table th[scope=row]{text-align:left;font-weight:500;color:var(--bt-ink-2)}
.bt-del-table thead th{font-weight:600;color:var(--bt-ink-3)}
.bt-del-ci,.bt-del-open{margin:0;font-size:12px;line-height:16px;color:var(--bt-ink-2)}
.bt-del-asof{margin:0;font-size:11.5px;line-height:15px;color:var(--bt-ink-3)}
.bt-del-columns{flex:1 1 auto;min-height:0;display:flex;gap:10px;overflow:auto}
.bt-del-column{flex:1 1 0;min-width:200px;display:flex;flex-direction:column;gap:8px;padding:8px;border-radius:14px;background:var(--bt-hover);overflow:auto}
.bt-del-card{display:flex;flex-direction:column;gap:5px;padding:10px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main))}
.bt-del-card-top{display:flex;align-items:center;gap:6px}
.bt-del-id{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums}
.bt-del-badge{margin-left:auto;font-size:10.5px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2)}
.bt-del-badge[data-badge=inc]{background:rgba(224,33,53,.12);color:#c21d2e}
.bt-del-badge[data-badge=ci-red]{background:rgba(224,33,53,.12);color:#c21d2e}
.bt-del-badge[data-badge=pass-enqueue],.bt-del-badge[data-badge=pass-merge]{background:color-mix(in srgb,var(--bt-green) 16%,transparent);color:color-mix(in srgb,var(--bt-green) 70%,var(--bt-ink))}
.bt-del-title{font-size:13.5px;line-height:19px;color:var(--bt-ink)}
.bt-del-labels{display:flex;flex-wrap:wrap;gap:4px}
.bt-del-label{font-size:10.5px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2)}
.bt-del-owner{font-size:12px;line-height:16px;color:var(--bt-ink-2)}
.bt-del-owner[data-kind=human]{font-weight:600;color:var(--bt-ink)}
.bt-del-linked,.bt-del-sha{font-size:11.5px;line-height:15px;color:var(--bt-ink-3)}
.bt-del-open{align-self:flex-start;font-size:12px;line-height:16px;color:var(--bt-accent)}
@media (max-width:800px){
.bt-del-columns{overflow:auto}
.bt-del-column{min-width:220px}
}
@media (max-width:640px){
.bt-del-columns{flex-direction:column}
.bt-del-column{min-width:0}
}
`
33 changes: 33 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,39 @@ const HOST_ZH = {
'This DeepSeek Harness cannot list folders': '这个 DeepSeek Harness 不能列出文件夹',
'No FlyBot core update is available': '没有可用的 FlyBot 核心更新',
'saved by a newer FlyBot; update FlyBot': '由更新的 FlyBot 保存;请更新 FlyBot',
// #1173: the delivery board — one repository-wide snapshot beside the per-Bot task board.
'Delivery': '交付',
'Board view': '看板视图',
'Original issue': '原始单',
'Ready to start': '待开工',
'In review': '审查中',
'Done today': '今天完成',
'INC · rework': '打回 · 返工',
'PASS · waiting for enqueue': '通过 · 待入队',
'PASS · waiting for merge': '通过 · 待合入',
'CI red': 'CI 红',
'CI running': 'CI 跑着',
'No verdict': '还没结论',
'New issues': '新单',
'Closed issues': '关单',
'New pull requests': '新 PR',
'Merged pull requests': '合入 PR',
'Median issue close': '关单中位',
'Median PR merge': '合入中位',
'Today': '今天',
'No GitHub connection yet; the board fills in once one is added.': '还没接 GitHub,接上后这里就有数了。',
'The delivery board is not ready yet.': '交付看板还没准备好。',
'Showing the last snapshot from {time}: {error}': '显示 {time} 的快照:{error}',
'linked to #{number}': '关联 #{number}',
'CI failed {failed} of {total}': 'CI 红 {failed} / 共 {total}',
'counted {counted} of {total}': '已统计 {counted} / 共 {total}',
'{count} superseded': '{count} 条被取代',
'Open: {issues} issues ({needs} need a human), {prs} pull requests': '开着:{issues} 张单({needs} 张等人),{prs} 个 PR',
'median open age {age}': '开着的单中位 {age}',
'as of {time}': '截至 {time}',
'refreshing…': '刷新中…',
'No snapshot yet.': '还没有快照。',
'Nothing here.': '这里还没有。',
}
Object.assign(ZH, HOST_ZH)

Expand Down
1 change: 1 addition & 0 deletions core/src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,7 @@ export function apply(ctx) {
setBotConnector: (botId, connector, enabled) => after(call('bot-connectors', { botId, connector, enabled })),
setBotCapability: (botId, name, attached) => after(call('bot-capability', { botId, name, attached })),
tasks: (payload) => call('tasks', payload ?? {}),
deliveryBoard: (payload) => call('delivery-board', payload ?? {}),
taskUpdate: (payload) => call('task-update', payload),
taskCreate: (payload) => call('task-create', payload),
schedules: () => call('schedules'),
Expand Down
25 changes: 18 additions & 7 deletions core/src/client/kanban.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createElement as h, useCallback, useEffect, useState } from 'react'
import { columnOf, priorityOf, progressOf } from '../shared/task-board.js'
import { CloseIcon } from './icons.js'
import { useEscape } from './overlay-hooks.js'
import { DeliveryView, DELIVERY_CSS } from './delivery-view.js'
import { t } from './i18n.js'

const POLL_MS = 15_000
Expand Down Expand Up @@ -315,6 +316,9 @@ function scopeName(roster, scope) {
}

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')
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 @@ -357,18 +361,25 @@ 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' }, t('Tasks')),
h('span', { className: 'bt-mem-status' }, name),
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('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))),
h(BoardView, {
roster, tasks, rail, scope, counts, dragging, error, draft,
onDraft: setDraft, onSelect: setScope, onMove: move, onDrag: setDragging, onCreate: create,
})))
view === 'delivery'
? h(DeliveryView, { actions })
: h(BoardView, {
roster, tasks, rail, scope, counts, dragging, error, draft,
onDraft: setDraft, onSelect: setScope, onMove: move, onDrag: setDragging, onCreate: create,
})))
}

export const KANBAN_CSS = `
.bt-board-row{min-height:40px;padding:6px 8px;gap:10px}
.bt-board-views{display:flex;gap:6px;align-items:center}
.bt-board-views .bt-soft[aria-selected=true]{background:var(--bt-active,var(--bt-hover));color:var(--bt-ink)}
.bt-board-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)}
.bt-board-mark svg{width:18px;height:18px}
.bt-board-label{flex:1 1 auto;min-width:0;font-size:14px;line-height:20px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
Expand Down Expand Up @@ -428,4 +439,4 @@ export const KANBAN_CSS = `
.bt-board-columns{flex-direction:column}
.bt-board-column{min-width:0}
}
`
` + DELIVERY_CSS
3 changes: 2 additions & 1 deletion core/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,8 @@ export function install(rt) {
// Anything not in the table and not a known auth/read endpoint is denied.
if (NO_ACTOR_ALLOWED.has(ep) || ep.startsWith('auth-')) return ALLOW
if (ep === 'state' || ep === 'models' || ep === 'efforts' || ep === 'repo-stars' || ep === 'update-check'
|| ep === 'diagnostics' || ep === 'diagnostics-log' || ep === 'usage') return ALLOW
|| ep === 'diagnostics' || ep === 'diagnostics-log' || ep === 'usage'
|| ep === 'delivery-board') return ALLOW

return deny('bot/not-found', `Unknown endpoint ${ep}`)
}
Expand Down
3 changes: 3 additions & 0 deletions core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,9 @@ export function install(rt) {
case 'task-answer-input':
await rt.load()
return ok(await rt.taskEndpoint(endpoint, body, actor))
case 'delivery-board':
await rt.load()
return ok(await rt.deliveryBoard(body))
case 'share-brief':
await rt.load()
await rt.setBriefShare(String(body.id ?? ''), String(body.botId ?? ''), body.share === true, actor)
Expand Down
Loading
Loading