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/2284-inbox-readonly-actions.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A read-only window's Inbox now keeps every row and its **Open** action and draws no write the host would refuse: no answer field or **Submit** on a question or a task input, and no **Accept** or **Send back** on a result. One sentence at the top of the dialog carries the host's own read-only words, the same hint the other read-only surfaces draw. An approval keeps **Reject** and **Allow**, because `approval-answer` is one of the reads a read-only channel allows, and a writable window draws exactly the tree it drew before. ([#2284](https://github.com/ThinkFlowLab/FlyBot/issues/2284))
6 changes: 4 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -9091,6 +9091,7 @@ function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) {
const answerable = card.kind === "approval" && card.approval?.answerable === true;
const evidence = card.kind === "task" && card.status === "result" ? card.task?.result?.links ?? [] : [];
const sentBack = card.kind === "task" && card.status === "task" ? card.task?.followUp?.note : void 0;
const locked = lockedWindow(roster);
const target = card.kind === "task" ? card.task?.target?.href : void 0;
const writeBack = card.kind === "task" && card.task?.writeBack?.state === "failed" ? card.task.writeBack : void 0;
const unconfirmed = card.kind === "task" && card.task?.writeBack?.state === "unconfirmed" ? card.task.writeBack : void 0;
Expand Down Expand Up @@ -9130,8 +9131,7 @@ function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) {
}, t("Comment goes to {target}", { target: target.replace(/^https:\/\/github\.com\//, "") }))) : null,
writeBack ? (0, import_react14.createElement)("span", { className: "bt-inbox-writeback", role: "alert", title: writeBack.error }, t("Could not comment on {target}: {error}", { target: writeBack.target, error: writeBack.error })) : null,
unconfirmed ? (0, import_react14.createElement)("span", { className: "bt-inbox-writeback", title: unconfirmed.target }, t("Could not confirm the comment on {target} went out", { target: unconfirmed.target })) : null,
(0, import_react14.createElement)(AnswerFields, { card, busy, onAnswer }),
(0, import_react14.createElement)(ResultActions, { card, busy, onAnswer }),
...locked ? [] : [(0, import_react14.createElement)(AnswerFields, { card, busy, onAnswer }), (0, import_react14.createElement)(ResultActions, { card, busy, onAnswer })],
error ? (0, import_react14.createElement)("span", { className: "bt-inbox-error", role: "alert" }, error) : null
),
(0, import_react14.createElement)(
Expand Down Expand Up @@ -9178,6 +9178,7 @@ function InboxDialog({ roster, useWork, useSessions, actions, leaving = false })
const timer = setTimeout(() => setFeedback(""), FEEDBACK_MS);
return () => clearTimeout(timer);
}, [feedback]);
const locked = lockedWindow(roster);
const model = inboxModel({ roster, work, tasks, sessions, workflows, now });
const groups = visibleNeeds(model, answered);
const answer = (card, choice) => {
Expand Down Expand Up @@ -9213,6 +9214,7 @@ function InboxDialog({ roster, useWork, useSessions, actions, leaving = false })
(0, import_react14.createElement)(
"div",
{ className: "bt-mem-body bt-inbox-body" },
...locked ? [(0, import_react14.createElement)("p", { key: "readonly", className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly))] : [],
(0, import_react14.createElement)("div", { className: "bt-inbox-feedback", role: "status", "aria-live": "polite" }, feedback),
GROUPS.map((group) => {
const settled = group.id === "done" ? model.doneAll ?? groups.done : null;
Expand Down
12 changes: 10 additions & 2 deletions core/src/client/inbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { botNames, currentPart, previewOf, relativeShort } from './text.js'
import { hostText, t } from './i18n.js'
import { failureSentence } from '../shared/failure-copy.js'
import { columnOf } from '../shared/task-board.js'
import { lockedWindow } from './schedules.js'

// Done (and a failed task) ages out after 7 days.
export const KEEP_MS = 7 * 86_400_000
Expand Down Expand Up @@ -549,6 +550,10 @@ export function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen })
// Where a decision is written back (#1154): the issue or PR the task came from is on the card, and
// a comment that could not be posted says so once, on the card, instead of staying in a log. A
// comment nobody posted within a day is `unconfirmed` (#1172): its own quiet line, not an error.
// #2284: a read-only window keeps every row and its Open action and draws no write the host
// would refuse (answer a question, the task input, Accept, Send back). An approval keeps its
// Reject and Allow: `approval-answer` is one of the reads a read-only channel allows.
const locked = lockedWindow(roster)
const target = card.kind === 'task' ? card.task?.target?.href : undefined
const writeBack = card.kind === 'task' && card.task?.writeBack?.state === 'failed' ? card.task.writeBack : undefined
const unconfirmed = card.kind === 'task' && card.task?.writeBack?.state === 'unconfirmed' ? card.task.writeBack : undefined
Expand All @@ -572,8 +577,7 @@ export function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen })
}, t('Comment goes to {target}', { target: target.replace(/^https:\/\/github\.com\//, '') }))) : null,
writeBack ? h('span', { className: 'bt-inbox-writeback', role: 'alert', title: writeBack.error }, t('Could not comment on {target}: {error}', { target: writeBack.target, error: writeBack.error })) : null,
unconfirmed ? h('span', { className: 'bt-inbox-writeback', title: unconfirmed.target }, t('Could not confirm the comment on {target} went out', { target: unconfirmed.target })) : null,
h(AnswerFields, { card, busy, onAnswer }),
h(ResultActions, { card, busy, onAnswer }),
...(locked ? [] : [h(AnswerFields, { card, busy, onAnswer }), h(ResultActions, { card, busy, onAnswer })]),
error ? h('span', { className: 'bt-inbox-error', role: 'alert' }, error) : null),
h('span', { className: 'bt-inbox-actions' },
answerable ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: () => onAnswer(card, 'reject') }, t('Reject')) : null,
Expand Down Expand Up @@ -622,6 +626,7 @@ export function InboxDialog({ roster, useWork, useSessions, actions, leaving = f
const timer = setTimeout(() => setFeedback(''), FEEDBACK_MS)
return () => clearTimeout(timer)
}, [feedback])
const locked = lockedWindow(roster)
const model = inboxModel({ roster, work, tasks, sessions, workflows, now })
const groups = visibleNeeds(model, answered)
const answer = (card, choice) => {
Expand Down Expand Up @@ -654,6 +659,9 @@ export function InboxDialog({ roster, useWork, useSessions, actions, leaving = f
h('span', { className: 'bt-mem-head-gap' }),
h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close inbox'), autoFocus: true, onClick: close }, h(CloseIcon))),
h('div', { className: 'bt-mem-body bt-inbox-body' },
// #2284: one sentence at the top of the dialog, in the host's own words, so a window that
// draws no write says why instead of leaving the reads silent about it.
...(locked ? [h('p', { key: 'readonly', className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly))] : []),
h('div', { className: 'bt-inbox-feedback', role: 'status', 'aria-live': 'polite' }, feedback),
GROUPS.map(group => {
// #2050: the model keeps every finished row, so the group can draw the page's remainder
Expand Down
157 changes: 157 additions & 0 deletions core/test/inbox-readonly.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { runInNewContext } from 'node:vm'
import { build } from 'esbuild'

// #2284: in a read-only window every Inbox action was a write the host refuses, and the window
// said nothing in place about why. The rows and their Open action stay; the four writes
// (answer a question, the task input, Accept, Send back) are not drawn, one sentence at the
// top of the dialog carries the host's own words, and an approval keeps Reject and Allow —
// `approval-answer` is one of the reads a read-only channel allows. A writable window draws
// exactly the tree it drew before this change: the hash below is taken from the unmodified
// tree, so the same fixture must keep its bytes.
const bundle = await build({
entryPoints: [fileURLToPath(new URL('../src/client/inbox.js', import.meta.url))],
bundle: true, format: 'cjs', platform: 'node', write: false,
external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'],
})
const module = { exports: {} }
let hookState = []
let hookCursor = 0
const effects = []
const NOW = Date.UTC(2026, 10, 3, 9, 0, 0)
// The card's meta line is a relative time read from the wall clock; freeze it so the recorded
// tree below is the same picture on every run.
class FrozenDate extends Date { static now() { return NOW } }
runInNewContext(bundle.outputFiles[0].text, {
module, exports: module.exports, setTimeout: () => 0, clearTimeout: () => {}, setInterval: () => 0, clearInterval: () => {}, Date: FrozenDate, Promise,
require: (name) => {
const platform = {
react: {
createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }),
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': {},
'@deepseek-ai/dsh-client-ui-primitives': {},
}
if (!(name in platform)) throw new Error(`no platform module ${name}`)
return platform[name]
},
})
const { InboxCard, InboxDialog } = module.exports

const OWN = new Set(['InboxCard', 'Status', 'AnswerFields', 'ResultActions'])
const expand = (node) => {
if (Array.isArray(node)) return node.flatMap(expand)
if (node === null || typeof node !== 'object') return node
if (OWN.has(node.type?.name)) return expand(node.type({ ...node.props, children: node.children }))
return { ...node, children: node.children.map(expand) }
}
const all = (node, out = []) => {
if (Array.isArray(node)) { for (const item of node) all(item, out); return out }
if (node && typeof node === 'object') { out.push(node); all(node.children, out) }
return out
}
const textOf = node => (typeof node === 'string' ? node : node && typeof node === 'object' ? node.children.map(textOf).join('') : '')
const byClass = (tree, name) => all(tree).filter(node => typeof node.props?.className === 'string' && node.props.className.split(' ').includes(name))
const buttons = tree => all(tree).filter(node => node.type === 'button')
const labels = tree => buttons(tree).map(textOf)
const fields = tree => all(tree).filter(node => node.type === 'input')

const LOCK = 'The Bot team is open in another dsh window.'
const bot = { id: 'b1', name: 'Anvil', ownerId: 'm1', sessionId: 's1' }
const writable = { self: { memberId: 'm1' }, bots: [bot], rooms: [], byId: { b1: bot }, roomsById: {}, readOnly: '' }
const locked = { ...writable, readOnly: LOCK }
const blank = { ...writable, readOnly: ' ' }
const missing = { ...writable }
delete missing.readOnly

const card = (extra) => ({ bot, at: NOW - 60_000, title: 'A card', chat: 'b1', ...extra })
const resultCard = card({ id: 'task:t4', kind: 'task', status: 'result', title: 'Draft is ready', task: { id: 't4', result: { summary: 'Draft is ready', links: [] } } })
const questionCard = card({ id: 'question:q1', kind: 'question', status: 'input', title: 'Which branch?', question: { id: 'q1', question: 'Which branch?', options: [{ label: 'A' }, { label: 'B' }], allowCustom: true } })
const inputCard = card({ id: 'task:t5', kind: 'task', status: 'input', title: 'Task five', task: { id: 't5', inputRequest: { id: 'i1', question: 'Which branch?' } } })
const approvalCard = card({ id: 'approval:a1', kind: 'approval', status: 'approval', title: 'pwsh', chat: 'r1', approval: { id: 'a1', tool: 'pwsh', answerable: true, roomId: 'r1' } })

const draw = (roster, drawn, extra = {}) => {
hookState = []
hookCursor = 0
return expand(InboxCard({ card: drawn, roster, now: NOW, busy: false, error: '', onAnswer: () => {}, onOpen: () => {}, ...extra }))
}
const dialog = (roster) => {
hookState = []
hookCursor = 0
return expand(InboxDialog({ roster, useWork: pick => pick({}), useSessions: pick => pick({ byId: {} }), actions: { tasks: () => Promise.resolve([]) } }))
}
// A normalised tree: element name, the props that decide what is drawn, and the text. Functions
// are not part of it, so the hash answers "same picture", not "same closures".
const shape = node => (typeof node === 'string' ? node
: Array.isArray(node) ? node.map(shape)
: node && typeof node === 'object'
? [node.type?.name ?? String(node.type), node.props?.className ?? '', node.props?.role ?? '', node.props?.title ?? '', node.props?.['aria-label'] ?? '',
...(node.type === 'input' ? [node.props?.type ?? '', node.props?.disabled === true ? 'off' : 'on'] : []),
...node.children.map(shape)]
: node)
const treeHash = (tree) => {
const bytes = Buffer.from(JSON.stringify(shape(tree)), 'utf8')
return { sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length }
}

// The writable picture, recorded on the unmodified tree. Both a blank and a missing `readOnly`
// stay on the writable path and draw exactly this.
const WRITABLE_SHA256 = 'b19823e6ca04389a3325e312aaf4807b84b55b882a255c3546d15c9c8ec23d41'
const WRITABLE_BYTES = 3088

test('a read-only result card keeps Open and draws no Accept and no Send back', () => {
assert.deepEqual(labels(draw(locked, resultCard)), ['Open'])
})

test('a read-only question card draws no options, no answer field and no Submit, only Open', () => {
const tree = draw(locked, questionCard)
assert.deepEqual(labels(tree), ['Open'])
assert.equal(fields(tree).length, 0)
})

test('a read-only task input draws no field and no Submit, only Open', () => {
const tree = draw(locked, inputCard)
assert.deepEqual(labels(tree), ['Open'])
assert.equal(fields(tree).length, 0)
})

test('the read-only dialog carries the host sentence once, at the top, as a status', () => {
const tree = dialog(locked)
const hints = byClass(tree, 'bt-read-only')
assert.equal(hints.length, 1)
assert.equal(hints[0].props.role, 'status')
assert.equal(textOf(hints[0]), LOCK)
const order = all(tree)
const hintAt = order.indexOf(hints[0])
const firstGroup = order.findIndex(node => node.props?.className === 'bt-inbox-group')
assert.ok(hintAt >= 0 && hintAt < firstGroup, `hint at ${hintAt}, first group at ${firstGroup}`)
// The writable dialog says nothing it did not say before.
assert.equal(byClass(dialog(writable), 'bt-read-only').length, 0)
})

test('an approval in a read-only window keeps Reject, Allow and its always-choices (#2284)', () => {
assert.deepEqual(labels(draw(locked, approvalCard)), ['Reject', 'Allow once', 'Always allow for this Bot', 'Always allow for this group', 'Open'])
})

test('a writable window still draws the writes and keeps the recorded tree byte for byte', () => {
assert.deepEqual(labels(draw(writable, resultCard)), ['Send back', 'Accept', 'Open'])
assert.deepEqual(labels(draw(writable, questionCard)), ['A', 'B', 'Submit', 'Open'])
assert.deepEqual(labels(draw(writable, inputCard)), ['Submit', 'Open'])
// A blank or missing `readOnly` is the writable path, not the locked one.
for (const roster of [writable, blank, missing]) {
assert.deepEqual(labels(draw(roster, resultCard)), ['Send back', 'Accept', 'Open'])
assert.equal(byClass(dialog(roster), 'bt-read-only').length, 0)
}
const hash = treeHash([resultCard, questionCard, inputCard, approvalCard].map(item => draw(writable, item)))
assert.equal(hash.sha256, WRITABLE_SHA256)
assert.equal(hash.bytes, WRITABLE_BYTES)
})
Loading
Loading