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/2198-board-done-rows.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A card in the Project board's **Done** group keeps its title, its priority and the board it opens: the done column no longer draws `#<task-id>` with no priority, and clicking the card opens the Bot or group the work lives on ([#2198](https://github.com/ThinkFlowLab/FlyBot/issues/2198)).
9 changes: 7 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -10622,6 +10622,11 @@ var MEMORY_TEXT_MAX = 600;
var memoryLength = (value) => [...text(value)].length;
var DONE_COLUMN = "done";
var boardOfRow = (row, task) => {
const stated = row?.board;
if (stated !== null && typeof stated === "object") {
const id = text(stated.id);
if (id !== "" && (stated.kind === "group" || stated.kind === "bot")) return { kind: stated.kind, id };
}
if (task === void 0) return null;
if (row.source === "group") {
const id = text(task.origin?.roomId);
Expand Down Expand Up @@ -10728,8 +10733,8 @@ function projectBoardView({ project, rows, done, canManage, tasks, decisions, me
assignee: text(row.assignee) || null,
runId: text(row.runId) || null,
nodeId: text(row.nodeId) || null,
title: text(task?.title) || text(task?.note) || null,
priority: text(task?.priority) || null,
title: text(row.title) || text(task?.title) || text(task?.note) || null,
priority: text(row.priority) || text(task?.priority) || null,
audit: text(row.audit) || null,
// #1831: who is doing the card, and who is speaking for whom. Both are host truth;
// a card carrying neither renders exactly as before.
Expand Down
24 changes: 17 additions & 7 deletions core/src/client/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,18 @@ const memoryLength = (value) => [...text(value)].length
// keeps them out of every group of work in flight.
const DONE_COLUMN = 'done'

// The board a row opens, read from the host's own `source` rather than re-deciding membership:
// a group row names its room, a bot row its owner, and a workflow row is one the run carried in,
// so it has no board of its own to open.
// The board a row opens. The host row names it (#2198), which is the only way a finished card can
// open at all: the `tasks` read keeps open cards, so a Done row has no record to derive it from.
// A row from a host that predates the field still derives it the old way — a group row names its
// room, a bot row its owner, and a workflow row is one the run carried in, so it has no board of
// its own to open. The kind is checked against the two boards a card can live on, because the
// value becomes a `#task=<kind>:<id>` hash and an unknown kind would land nowhere.
const boardOfRow = (row, task) => {
const stated = row?.board
if (stated !== null && typeof stated === 'object') {
const id = text(stated.id)
if (id !== '' && (stated.kind === 'group' || stated.kind === 'bot')) return { kind: stated.kind, id }
}
if (task === undefined) return null
if (row.source === 'group') {
const id = text(task.origin?.roomId)
Expand Down Expand Up @@ -256,8 +264,10 @@ export function projectBoardRecord({ decisions, meetings } = {}) {

// Pure: no fetch, no React, no store. `rows` is the host's read model, `canManage` comes from
// `project-read` and only decides what the caller may *ask* for — the host refuses regardless.
// `tasks` is the one task read joined by id: the row carries an id and who holds it, not its text.
// `decisions` and `meetings` are the host's read of the Project's record (#1767), passed through.
// `tasks` is the one task read joined by id, and it is a fallback only: the row carries the card's
// own title, priority and board, which is all a finished card has (the `tasks` read keeps open
// cards, so a Done card has no record there to join). `decisions` and `meetings` are the host's read
// of the Project's record (#1767), passed through.
// `queues` is the other half of that same read (#1768): the waiting, in-flight and blocked cards
// and their counts, as the host answered them.
export function projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics } = {}) {
Expand Down Expand Up @@ -288,8 +298,8 @@ export function projectBoardView({ project, rows, done, canManage, tasks, decisi
assignee: text(row.assignee) || null,
runId: text(row.runId) || null,
nodeId: text(row.nodeId) || null,
title: text(task?.title) || text(task?.note) || null,
priority: text(task?.priority) || null,
title: text(row.title) || text(task?.title) || text(task?.note) || null,
priority: text(row.priority) || text(task?.priority) || null,
audit: text(row.audit) || null,
// #1831: who is doing the card, and who is speaking for whom. Both are host truth;
// a card carrying neither renders exactly as before.
Expand Down
13 changes: 12 additions & 1 deletion core/src/host/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
// sections. It writes nothing, starts nothing and is not called unless someone asks for the board.
import { open } from 'node:fs/promises'
import { join } from 'node:path'
import { columnOf } from '../shared/task-board.js'
import { columnOf, priorityOf } from '../shared/task-board.js'
import { DECISIONS_DIR, describeFile, isDecisionPath, listProjectState, readListedFile } from './project-state.js'
import { cut } from './text.js'
import { advanceRefusal } from './meetings.js'
Expand Down Expand Up @@ -491,9 +491,20 @@ export function install(rt) {
mine.push(task)
const fromRun = ref !== undefined && (runProject === null || runProject.id === id)
const audit = typeof rt.taskAudit === 'function' ? rt.taskAudit(task.id) : ''
const title = text(task.title)
rows.push({
id: task.id,
source,
// The card's own text and priority, read here so a row is enough to draw a card. A finished
// card is not in the open `tasks` read, and a join by id would leave the Done column with
// `#<id>` and no priority; a record stored before this field reads `null` / `'normal'`.
title: title === '' ? null : title,
priority: priorityOf(task),
// The board this row opens (#2198), so a finished card — which the open `tasks` read no
// longer lists — still opens from the Done column instead of drawing as a dead card. It
// names what `source` names: a run-carried row (`source: 'workflow'`) has no board of its
// own to open, which is the same `null` the client derives for it today.
board: source === 'workflow' || board === null ? null : { kind: board.kind, id: board.id },
column: columnOf(task),
assignee: task.assignee ?? null,
// #1831: "who is doing" is the claim, not the assignee label, and "who is speaking for
Expand Down
27 changes: 27 additions & 0 deletions core/test/project-board-view.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,33 @@ test('the board groups rows by their source and keeps each task id', () => {
assert.deepEqual(view.done.rows.map(row => row.id), ['task-g1'], 'the finished row is the archive')
})

// P-167: the `tasks` read keeps open cards only, so a finished card has no record there to join.
// The row carries the card's own text and priority, and the Done column draws that — never `#<id>`.
test('#P-167: a finished card keeps its title and priority with no task record to join', () => {
const done = { id: 'task-done', source: 'bot', column: 'done', assignee: null, updatedAt: 7, title: 'Ship the release', priority: 'high' }
const view = plain(projectBoardView({ project: { id: 'p', name: 'P' }, rows: [], done: [done], tasks: [] }))
assert.equal(view.done.rows[0].title, 'Ship the release')
assert.equal(view.done.rows[0].priority, 'high')
const drawn = all(ProjectBoardPane({ project: { id: 'p', name: 'FlyBot' }, rows: [], done: [done], tasks: [] }))
const title = drawn.find(node => node.props?.className === 'bt-project-title')
assert.equal(textOf(title), 'Ship the release')
})

// #2198: a finished card is not only text — it is a card, so it opens the board it lives on. With
// no `tasks` record to join, the board has to come from the row; without it the card drew as a
// dead row with no click at all.
test('#2198: a finished card opens the board its row names, with no task record to join', () => {
const done = { id: 'task-done', source: 'group', column: 'done', assignee: null, updatedAt: 7, title: 'Ship the release', priority: 'high', board: { kind: 'group', id: 'r1' } }
const view = plain(projectBoardView({ project: { id: 'p', name: 'P' }, rows: [], done: [done], tasks: [] }))
assert.deepEqual(view.done.rows[0].board, { kind: 'group', id: 'r1' })
const opened = []
const drawn = all(ProjectBoardPane({ project: { id: 'p', name: 'FlyBot' }, rows: [], done: [done], tasks: [], onOpen: board => opened.push(board) }))
const card = drawn.find(node => node.props?.className === 'bt-project-row')
assert.equal(card.props.role, 'button', 'the finished card is offered as a control')
card.props.onClick()
assert.deepEqual(plain(opened), [{ kind: 'group', id: 'r1' }])
})

test('a workflow row carries its run and node; other rows carry none', () => {
const view = plain(projectBoardView({ project: { id: 'p', name: 'P' }, rows, canManage: false }))
const [botRow] = view.groups[0].rows
Expand Down
49 changes: 47 additions & 2 deletions core/test/project-board.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,9 @@ test('a Project board reads a group card and a run-created card without repeatin
const rows = (await rt.projectBoard('project-p1')).rows
assert.equal(rows.length, 2)
const group = rows.find(row => row.id === 'task-group')
assert.deepEqual(group, { id: 'task-group', source: 'group', column: 'in-progress', assignee: 'Writer', updatedAt: 10, runId: 'run-2', nodeId: 'planner' })
assert.deepEqual(group, { id: 'task-group', source: 'group', title: 'Ship it', priority: 'normal', board: { kind: 'group', id: 'r1' }, column: 'in-progress', assignee: 'Writer', updatedAt: 10, runId: 'run-2', nodeId: 'planner' })
const run = rows.find(row => row.id === 'task-run')
assert.deepEqual(run, { id: 'task-run', source: 'workflow', column: 'in-progress', assignee: null, updatedAt: 20, runId: 'run-1', nodeId: 'writer' })
assert.deepEqual(run, { id: 'task-run', source: 'workflow', title: 'Run card', priority: 'normal', board: null, column: 'in-progress', assignee: null, updatedAt: 20, runId: 'run-1', nodeId: 'writer' })
// Never the same task twice.
assert.equal(new Set(rows.map(row => row.id)).size, rows.length)
} finally {
Expand All @@ -111,6 +111,51 @@ test('a Project board reads a group card and a run-created card without repeatin
}
})

// P-167: a finished card is still a card. The `tasks` read lists open cards only, so the Done
// column has no task record to join against — the row itself has to carry the text and the
// priority, or the card draws as `#<id>` with no priority at all.
test('a finished card carries its title and priority on the row the Done column draws', async () => {
const team = seed()
team.tasks['task-done'] = { id: 'task-done', kind: 'group', status: 'accepted', ownerBotId: 'a', origin: { roomId: 'r1' }, assignee: 'Writer', title: 'Ship the release', priority: 'high', createdAt: 3, updatedAt: 4 }
const { rt, stop } = await start(undefined, team)
try {
const board = await rt.projectBoard('project-p1')
const card = board.done.find(row => row.id === 'task-done')
assert.equal(card.title, 'Ship the release')
assert.equal(card.priority, 'high')
// The read the page joins against keeps open cards only, so a finished card is not in it.
const listed = await rt.taskEndpoint('tasks', {}, null)
assert.equal(listed.some(task => task.id === 'task-done'), false)
} finally {
await stop()
}
})

// #2198: a row names the board it opens, so the Done column is not a shelf of dead cards. With no
// `tasks` record to join, the client had no way to derive the board and the card drew without a
// click at all; the row carries it, and a run-carried row keeps naming none of its own.
test('a finished card names the board it opens on the row the Done column draws', async () => {
const team = seed()
team.tasks['task-done'] = { id: 'task-done', kind: 'group', status: 'accepted', ownerBotId: 'a', origin: { roomId: 'r1' }, assignee: 'Writer', title: 'Ship the release', priority: 'high', createdAt: 3, updatedAt: 4 }
const { rt, stop } = await start(undefined, team)
const off = rt.onProjectProvider(({ hasTask }) => [
{ taskId: 'task-run', runId: 'run-1' },
].filter(row => hasTask(row.taskId)))
try {
const board = await rt.projectBoard('project-p1')
const card = board.done.find(row => row.id === 'task-done')
assert.deepEqual(card.board, { kind: 'group', id: 'r1' })
// The run carried this card in, and `source: 'workflow'` is the run's binding, not a board.
const runCard = board.rows.find(row => row.id === 'task-run')
assert.equal(runCard.source, 'workflow')
assert.equal(runCard.board, null)
} finally {
off()
await stop()
}
})


test('the board takes its token from the host secret store and its zone from the call', async () => {
const { rt, stop } = await start()
const realSecret = rt.secretValue
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,4 +446,4 @@ inferred from an assignee label.

## Project board metrics and the running table (#1829)

`core/src/client/project-board.js` draws the seven cards the host's `project-board` read carries in `metrics`: four day counts and three seven-day medians, each with a one-line caption naming the field and the window. A value the host answered `null` renders `—` (u+2014) and never `0`; an `ok: false` read draws the same cards plus one line naming the host's reason. `metricsView` is pure and takes the host's object as it came. A meeting row also draws the proxy line (#1861): `projectBoardRecord` keeps each meeting's `participants` with their `speaksFor`, and the decided and pending rows render every proxy as `<name> for {user}` in the same `for {user}` words a task row uses for its holder, with the spoken-for ids also on `data-speaks-for`. The running table below renders `metrics.running` — a claimed card's title, status, holder, claim age (from the claim's own start), source and last activity — marks a row whose claim passed `RUNNING_OVERDUE_MS` and sends a click through the same `onOpen` the board rows use; with no rows it draws one empty line. The Waiting / In flight / Blocked numbers stay on the one-line count header beside those groups.
`core/src/client/project-board.js` draws the seven cards the host's `project-board` read carries in `metrics`: four day counts and three seven-day medians, each with a one-line caption naming the field and the window. A value the host answered `null` renders `—` (u+2014) and never `0`; an `ok: false` read draws the same cards plus one line naming the host's reason. `metricsView` is pure and takes the host's object as it came. A meeting row also draws the proxy line (#1861): `projectBoardRecord` keeps each meeting's `participants` with their `speaksFor`, and the decided and pending rows render every proxy as `<name> for {user}` in the same `for {user}` words a task row uses for its holder, with the spoken-for ids also on `data-speaks-for`. The running table below renders `metrics.running` — a claimed card's title, status, holder, claim age (from the claim's own start), source and last activity — marks a row whose claim passed `RUNNING_OVERDUE_MS` and sends a click through the same `onOpen` the board rows use; with no rows it draws one empty line. The Waiting / In flight / Blocked numbers stay on the one-line count header beside those groups. A card's title, priority and board come off the row the host sent and fall back to the `tasks` read joined by id — that read keeps open cards only, so a finished card in the `done` half has no record to join and would otherwise draw as `#<id>` with no priority and no board to open; `boardOfRow` prefers the row's `{ kind, id }` and derives it only when the row carries none, and a `workflow` row keeps no board. A row stored or served before the row carried text still reads exactly as it did.
Loading
Loading