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/2050-inbox-show-all.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Inbox counted the finished tasks its Done group left out but gave no way to see them: with 29 finished tasks the group drew 20, said `9 more not shown`, and those nine were unreachable anywhere in the UI. The model now keeps every finished row and the Done group has a **Show all** / **Show fewer** control that draws the rest in place; collapsed, and at or under the cap, the group reads exactly as before.
21 changes: 18 additions & 3 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,8 @@ var ZH = {
Inbox: "收件箱",
"Inbox, {count} need you": "收件箱,{count} 项需要你",
"Close inbox": "关闭收件箱",
"Show all": "显示全部",
"Show fewer": "收起",
"Your Bots": "你的 Bot",
"Needs you": "需要你",
"In progress": "进行中",
Expand Down Expand Up @@ -8229,7 +8231,7 @@ function inboxModel({ roster, work = {}, tasks = [], sessions, workflows = [], n
card.calls = toolCalls(card, { roster, work });
}
const settled = done.sort(newest);
return { needs: needs.sort(newest), progress: progress.sort(newest), done: settled.slice(0, DONE_MAX), doneTotal: settled.length };
return { needs: needs.sort(newest), progress: progress.sort(newest), done: settled.slice(0, DONE_MAX), doneAll: settled, doneTotal: settled.length };
}
var needsCount = (roster, work, tasks, now, workflows = []) => inboxModel({ roster, work, tasks, now, workflows }).needs.length;
function startedAtOf(task) {
Expand Down Expand Up @@ -8498,6 +8500,7 @@ function InboxDialog({ roster, useWork, useSessions, actions, leaving = false })
const [busy, setBusy] = (0, import_react13.useState)("");
const [errors, setErrors] = (0, import_react13.useState)({});
const [feedback, setFeedback] = (0, import_react13.useState)("");
const [allDone, setAllDone] = (0, import_react13.useState)(false);
const close = (0, import_react13.useCallback)(() => actions.closeInbox(), []);
useEscape(close);
(0, import_react13.useEffect)(() => {
Expand Down Expand Up @@ -8542,7 +8545,9 @@ function InboxDialog({ roster, useWork, useSessions, actions, leaving = false })
{ className: "bt-mem-body bt-inbox-body" },
(0, import_react13.createElement)("div", { className: "bt-inbox-feedback", role: "status", "aria-live": "polite" }, feedback),
GROUPS.map((group) => {
const rows = groups[group.id];
const settled = group.id === "done" ? model.doneAll ?? groups.done : null;
const canOpen = group.id === "done" && settled.length > DONE_MAX;
const rows = canOpen && allDone ? settled : groups[group.id];
const hidden = group.id === "done" ? Math.max(0, (model.doneTotal ?? rows.length) - rows.length) : 0;
return (0, import_react13.createElement)(
"section",
Expand All @@ -8563,7 +8568,14 @@ function InboxDialog({ roster, useWork, useSessions, actions, leaving = false })
onAnswer: answer,
onOpen: open
}))),
hidden > 0 ? (0, import_react13.createElement)("div", { key: "more", className: "bt-inbox-more" }, t("{count} more not shown", { count: hidden })) : null
hidden > 0 ? (0, import_react13.createElement)("div", { key: "more", className: "bt-inbox-more" }, t("{count} more not shown", { count: hidden })) : null,
canOpen ? (0, import_react13.createElement)("button", {
key: "all",
type: "button",
className: "bt-inbox-show-all",
"aria-expanded": allDone,
onClick: () => setAllDone((value) => !value)
}, t(allDone ? "Show fewer" : "Show all")) : null
);
})
)
Expand All @@ -8586,6 +8598,9 @@ var INBOX_CSS = `
.bt-inbox-head-count{font-weight:500;color:var(--bt-ink-3);font-variant-numeric:tabular-nums}
.bt-inbox-empty{margin:0;font-size:13px;line-height:18px;color:var(--bt-ink-3)}
.bt-inbox-more{margin:0;font-size:12.5px;line-height:18px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums}
.bt-inbox-show-all{align-self:flex-start;padding:0;border:0;background:none;font:inherit;font-size:12.5px;line-height:18px;color:var(--bt-accent);cursor:pointer}
.bt-inbox-show-all:hover{text-decoration:underline}
.bt-inbox-show-all:focus-visible{outline:2px solid color-mix(in srgb,var(--bt-accent) 45%,transparent);outline-offset:2px;border-radius:4px}
.bt-inbox-list{display:flex;flex-direction:column;gap:6px}
.bt-inbox-card{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main))}
.bt-inbox-card:focus-within{box-shadow:0 0 0 2px color-mix(in srgb,var(--bt-accent) 35%,transparent)}
Expand Down
2 changes: 2 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,8 @@ const ZH = {
Inbox: '收件箱',
'Inbox, {count} need you': '收件箱,{count} 项需要你',
'Close inbox': '关闭收件箱',
'Show all': '显示全部',
'Show fewer': '收起',
'Your Bots': '你的 Bot',
'Needs you': '需要你',
'In progress': '进行中',
Expand Down
21 changes: 18 additions & 3 deletions core/src/client/inbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -357,8 +357,10 @@ export function inboxModel({ roster, work = {}, tasks = [], sessions, workflows
}
// #2042: Done is capped, and the cap used to be silent — the group counted the rows it drew,
// so 25 finished tasks read as 20. The sorted list keeps the count it had before the cut.
// #2050: the cap is a page, not the history — the rows past it stay in the model (`doneAll`),
// so the group can draw them in place instead of only saying how many it left out.
const settled = done.sort(newest)
return { needs: needs.sort(newest), progress: progress.sort(newest), done: settled.slice(0, DONE_MAX), doneTotal: settled.length }
return { needs: needs.sort(newest), progress: progress.sort(newest), done: settled.slice(0, DONE_MAX), doneAll: settled, doneTotal: settled.length }
}

// The row's badge: how many cards need me. The Done list needs no Session list for this.
Expand Down Expand Up @@ -611,6 +613,8 @@ export function InboxDialog({ roster, useWork, useSessions, actions, leaving = f
const [busy, setBusy] = useState('')
const [errors, setErrors] = useState({})
const [feedback, setFeedback] = useState('')
// #2050: Done's cap is a page of the history; this draws the rest in place, on demand.
const [allDone, setAllDone] = useState(false)
const close = useCallback(() => actions.closeInbox(), [])
useEscape(close)
useEffect(() => {
Expand Down Expand Up @@ -652,7 +656,11 @@ export function InboxDialog({ roster, useWork, useSessions, actions, leaving = f
h('div', { className: 'bt-mem-body bt-inbox-body' },
h('div', { className: 'bt-inbox-feedback', role: 'status', 'aria-live': 'polite' }, feedback),
GROUPS.map(group => {
const rows = groups[group.id]
// #2050: the model keeps every finished row, so the group can draw the page's remainder
// in place. Below the cap there is nothing to open, and it draws exactly what it did.
const settled = group.id === 'done' ? (model.doneAll ?? groups.done) : null
const canOpen = group.id === 'done' && settled.length > DONE_MAX
const rows = canOpen && allDone ? settled : groups[group.id]
// #2042: Done draws DONE_MAX rows at most. When the model had more, one line under the
// rows says how many it left out, in the words the project board's capped list uses (#2021).
const hidden = group.id === 'done' ? Math.max(0, (model.doneTotal ?? rows.length) - rows.length) : 0
Expand All @@ -665,7 +673,11 @@ export function InboxDialog({ roster, useWork, useSessions, actions, leaving = f
: h('div', { className: 'bt-inbox-list' }, rows.map(card => h(InboxCard, {
key: card.id, card, roster, now, busy: busy === card.id, error: errors[card.id], onAnswer: answer, onOpen: open,
}))),
hidden > 0 ? h('div', { key: 'more', className: 'bt-inbox-more' }, t('{count} more not shown', { count: hidden })) : null)
hidden > 0 ? h('div', { key: 'more', className: 'bt-inbox-more' }, t('{count} more not shown', { count: hidden })) : null,
canOpen ? h('button', {
key: 'all', type: 'button', className: 'bt-inbox-show-all', 'aria-expanded': allDone,
onClick: () => setAllDone(value => !value),
}, t(allDone ? 'Show fewer' : 'Show all')) : null)
}))))
}

Expand All @@ -685,6 +697,9 @@ export const INBOX_CSS = `
.bt-inbox-head-count{font-weight:500;color:var(--bt-ink-3);font-variant-numeric:tabular-nums}
.bt-inbox-empty{margin:0;font-size:13px;line-height:18px;color:var(--bt-ink-3)}
.bt-inbox-more{margin:0;font-size:12.5px;line-height:18px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums}
.bt-inbox-show-all{align-self:flex-start;padding:0;border:0;background:none;font:inherit;font-size:12.5px;line-height:18px;color:var(--bt-accent);cursor:pointer}
.bt-inbox-show-all:hover{text-decoration:underline}
.bt-inbox-show-all:focus-visible{outline:2px solid color-mix(in srgb,var(--bt-accent) 45%,transparent);outline-offset:2px;border-radius:4px}
.bt-inbox-list{display:flex;flex-direction:column;gap:6px}
.bt-inbox-card{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main))}
.bt-inbox-card:focus-within{box-shadow:0 0 0 2px color-mix(in srgb,var(--bt-accent) 35%,transparent)}
Expand Down
80 changes: 78 additions & 2 deletions core/test/inbox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,23 @@ const module = { exports: {} }
// A hook's effects are collected, never run: a test that drives a load calls the one it needs.
// Timers are stubs so a driven load does not arm a background poll.
const effects = []
// #2050: a hook's state lives per render pass, so a control that flips one can be tested without
// React. A draw rewinds the cursor and a setter writes the slot the next pass reads back; the
// `useState` calls before a component's own slots run in the same order on both passes.
let hookState = []
let hookCursor = 0
const resetInboxHooks = () => { hookState = []; hookCursor = 0 }
runInNewContext(bundle.outputFiles[0].text, {
module, exports: module.exports, setTimeout: () => 0, clearTimeout: () => {}, setInterval: () => 0, clearInterval: () => {}, Date, Promise,
require: (name) => {
const platform = {
react: {
createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }),
useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}],
useState: initial => {
const index = hookCursor++
if (!(index in hookState)) hookState[index] = typeof initial === 'function' ? initial() : initial
return [hookState[index], value => { hookState[index] = typeof value === 'function' ? value(hookState[index]) : value }]
},
useEffect: fn => { effects.push(fn) }, useLayoutEffect: () => {}, useRef: current => ({ current }), useCallback: fn => fn, useMemo: fn => fn(), Fragment: 'fragment',
},
'react-dom': {},
Expand Down Expand Up @@ -815,14 +825,21 @@ const emptyInbox = extra => inboxModel({ roster, work: {}, sessions: undefined,
const serveInboxTasks = async (rows) => {
const actions = { closeInbox: () => {}, tasks: () => Promise.resolve(rows) }
effects.length = 0
hookCursor = 0
useTasks(actions)
effects.pop()()
await new Promise(resolve => setImmediate(resolve))
return actions
}
const drawInboxDialog = actions => InboxDialog({ roster, useWork: pick => pick({}), useSessions: pick => pick({}), actions })
// A fresh draw starts from the initial state; `keep` redraws on the state a click just wrote.
const drawInboxDialog = (actions, { keep = false } = {}) => {
if (keep) hookCursor = 0
else resetInboxHooks()
return InboxDialog({ roster, useWork: pick => pick({}), useSessions: pick => pick({}), actions })
}
const resetInboxTasks = async () => {
effects.length = 0
hookCursor = 0
useTasks({ closeInbox: () => {}, tasks: () => Promise.resolve([]) })
effects.pop()()
await new Promise(resolve => setImmediate(resolve))
Expand Down Expand Up @@ -884,3 +901,62 @@ test('#2042: the Done group says how many rows the cap hid, and says nothing whe

await resetInboxTasks()
})

// #2050: #2042 made the group say how many rows it hid, but the model dropped them, so the count
// line was dead text — nothing in the product could open the rows past the cap, and 9 of 29
// finished tasks were unreachable. The model keeps them now, and the group can draw them in place.
test('#2050: the model keeps the finished rows past the cap, in the order the Done group draws', () => {
const capped = emptyInbox({ tasks: freshTasks(29) })
assert.equal(capped.done.length, 20, 'the cap still draws one page')
assert.equal(capped.doneAll.length, 29, 'and the model carries every finished row')
assert.equal(capped.doneTotal, 29, 'the count is unchanged')
assert.deepEqual(plain(ids(capped.doneAll.slice(0, 20))), plain(ids(capped.done)), 'the page is the head of that list, same order')
assert.deepEqual(plain(ids(capped.doneAll)), plain(freshTasks(29).map(row => `task:${row.id}`)), 'newest settled first, as the group draws them')

const short = emptyInbox({ tasks: freshTasks(5) })
assert.deepEqual(plain(ids(short.doneAll)), plain(ids(short.done)), 'a short list keeps exactly what it draws')
assert.deepEqual(plain(emptyInbox({ tasks: [] }).doneAll), [], 'an empty list keeps nothing')
})

test('#2050: the Done group opens every row it counted, and folds back to the cap', async () => {
const more = tree => byClass(tree, 'bt-inbox-more')
const control = tree => byClass(tree, 'bt-inbox-show-all')
const doneRows = tree => cardsIn(tree).filter(node => node.props.card.id.startsWith('task:d'))
const groupIds = (tree, id) => cardsIn(all(tree).find(node => node.type === 'section' && node.props['aria-labelledby'] === `bt-inbox-${id}`)).map(node => node.props.card.id)

const rows = [task('n1', 'failed', { actors: [{ status: 'failed', why: 'the responsible Bot was deleted' }] }), task('p1', 'working'), ...freshTasks(29)]
const actions = await serveInboxTasks(rows)

const collapsed = drawInboxDialog(actions)
assert.equal(doneRows(collapsed).length, 20, 'the cap still draws 20 rows')
assert.equal(textOf(more(collapsed)[0]), '9 more not shown', 'and still says what it hid')
const open = control(collapsed)
assert.equal(open.length, 1, 'one control offers the hidden rows')
assert.equal(open[0].type, 'button', 'it is a control, not dead text')
assert.equal(textOf(open[0]), 'Show all')
assert.equal(open[0].props['aria-expanded'], false, 'and reads as folded')

open[0].props.onClick()
const expanded = drawInboxDialog(actions, { keep: true })
assert.equal(doneRows(expanded).length, 29, 'every finished row is drawn in place')
assert.deepEqual(plain(doneRows(expanded).map(node => node.props.card.id)), plain(freshTasks(29).map(row => `task:${row.id}`)), 'the rows and their order are the ones the group already had')
assert.equal(more(expanded).length, 0, 'nothing is hidden, so the count line is gone')
assert.equal(textOf(control(expanded)[0]), 'Show fewer')
assert.equal(control(expanded)[0].props['aria-expanded'], true)
assert.deepEqual(plain(groupIds(expanded, 'needs')), plain(groupIds(collapsed, 'needs')), 'Needs you is untouched')
assert.deepEqual(plain(groupIds(expanded, 'progress')), plain(groupIds(collapsed, 'progress')), 'nor is In progress')

control(expanded)[0].props.onClick()
const folded = drawInboxDialog(actions, { keep: true })
assert.equal(doneRows(folded).length, 20, 'closing the toggle returns to the cap')
assert.equal(textOf(more(folded)[0]), '9 more not shown')
assert.equal(textOf(control(folded)[0]), 'Show all')

// Nothing is hidden at or under the cap, so there is no control and the group is as it was.
const short = drawInboxDialog(await serveInboxTasks(freshTasks(20)))
assert.equal(doneRows(short).length, 20)
assert.equal(control(short).length, 0, 'a page with nothing past it offers no control')
assert.equal(more(short).length, 0)

await resetInboxTasks()
})
Loading
Loading