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/401-accept-or-send-back-a-result.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A finished task can now be accepted or sent back right in the inbox ([#401](https://github.com/ThinkFlowLab/FlyBot/issues/401)): the result card shows the Bot's evidence links and model, **Accept** closes the task as done, and **Send back** asks for one line about what should change and returns the same task to the Bot.
81 changes: 80 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,12 @@ var ZH = {
"Latest: {text}": "最新:{text}",
"{count} tool calls": "{count} 次工具调用",
"Untitled task": "未命名任务",
// Accepting a result in Done (#401)
Accept: "接受",
"Send back": "退回",
"Send it back": "退回给 Bot",
"What should change": "需要改什么",
"Sent back: {note}": "已退回:{note}",
// Bot task board (#1058)
Tasks: "任务",
"Close tasks": "关闭任务",
Expand Down Expand Up @@ -7539,6 +7545,12 @@ function inboxAnswer(actions, card, choice) {
if (text === "" || typeof questionId !== "string") return void 0;
return Promise.resolve(actions.answerTaskInput(card.task.id, questionId, text));
}
if (card?.kind === "task" && card.status === "result") {
if (choice === "accept") return Promise.resolve(actions.acceptTaskResult(card.task.id));
const note = choice !== null && typeof choice === "object" ? String(choice.note ?? "").trim() : "";
if (note === "") return void 0;
return Promise.resolve(actions.sendBackTask(card.task.id, note));
}
if (card?.kind === "approval" && card.approval?.answerable === true) {
return Promise.resolve(actions.answerApproval(card.approval.id, choice));
}
Expand All @@ -7553,6 +7565,51 @@ var answerSlot = (id) => {
}
return slot;
};
var resultSlots = /* @__PURE__ */ new Map();
var resultSlot = (id) => {
let slot = resultSlots.get(id);
if (slot === void 0) {
slot = { open: false, text: "" };
resultSlots.set(id, slot);
}
return slot;
};
function ResultActions({ card, busy, onAnswer }) {
const [, redraw] = (0, import_react13.useState)(0);
if (!(card.kind === "task" && card.status === "result")) return null;
const slot = resultSlot(card.id);
const send = () => {
const note = slot.text.trim();
if (busy || note === "") return;
onAnswer(card, { note });
};
return (0, import_react13.createElement)(
"span",
{ className: "bt-inbox-result" },
slot.open ? (0, import_react13.createElement)("input", {
type: "text",
className: "bt-inbox-answer-text",
value: slot.text,
disabled: busy,
"aria-label": t("What should change"),
placeholder: t("What should change"),
onChange: (event) => {
slot.text = String(event.target?.value ?? "");
redraw((count) => count + 1);
},
onKeyDown: (event) => {
if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return;
event.preventDefault();
send();
}
}) : null,
slot.open ? (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Send it back")) : (0, import_react13.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => {
slot.open = true;
redraw((count) => count + 1);
} }, t("Send back")),
(0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "accept") }, t("Accept"))
);
}
function AnswerFields({ card, busy, onAnswer }) {
const [, redraw] = (0, import_react13.useState)(0);
const question = card.kind === "question";
Expand Down Expand Up @@ -7648,8 +7705,11 @@ function whenText(card, now) {
}
function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) {
const who = card.bot ? card.bot.name : card.room?.name ?? "";
const meta = [who, card.place, whenText(card, now)].filter(Boolean).join(" · ");
const produced = card.kind === "task" && card.status === "result" ? [card.bot?.model] : [];
const meta = [who, ...produced, card.place, whenText(card, now)].filter(Boolean).join(" · ");
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;
return (0, import_react13.createElement)(
"div",
{ className: "bt-inbox-card", role: "group", "aria-label": `${t(STATUSES[card.status].label)}: ${card.title}`, "data-status": card.status },
Expand All @@ -7668,7 +7728,17 @@ function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) {
card.reason ? (0, import_react13.createElement)("span", { className: "bt-inbox-reason", title: card.reason }, card.reason) : null,
card.activity ? (0, import_react13.createElement)("span", { className: "bt-inbox-activity", title: card.activity }, t("Latest: {text}", { text: card.activity })) : null,
card.hint ? (0, import_react13.createElement)("span", { className: "bt-inbox-hint" }, card.hint) : null,
evidence.length > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-evidence" }, ...evidence.map((href, index) => (0, import_react13.createElement)("a", {
key: `${href}-${index}`,
href,
target: "_blank",
rel: "noreferrer noopener",
className: "bt-inbox-evidence-link",
title: href
}, href))) : null,
sentBack ? (0, import_react13.createElement)("span", { className: "bt-inbox-reason", title: sentBack }, t("Sent back: {note}", { note: sentBack })) : null,
(0, import_react13.createElement)(AnswerFields, { card, busy, onAnswer }),
(0, import_react13.createElement)(ResultActions, { card, busy, onAnswer }),
error ? (0, import_react13.createElement)("span", { className: "bt-inbox-error", role: "alert" }, error) : null
),
(0, import_react13.createElement)(
Expand Down Expand Up @@ -7802,6 +7872,11 @@ var INBOX_CSS = `
.bt-inbox-calls{font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
.bt-inbox-activity{color:var(--bt-ink-3)}
.bt-inbox-error{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere}
.bt-inbox-evidence{display:flex;flex-wrap:wrap;gap:6px;font-size:12px;line-height:16px}
.bt-inbox-evidence-link{color:var(--bt-blue,#3a7afe);text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.bt-inbox-evidence-link:hover{text-decoration:underline}
.bt-inbox-result{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px}
.bt-inbox-result .bt-send,.bt-inbox-result .bt-soft{height:28px;font-size:12px}
.bt-inbox-answer{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px}
.bt-inbox-answer-text{flex:1 1 140px;min-width:0;height:28px;padding:0 8px;border:.5px solid var(--bt-line-2);border-radius:8px;background:transparent;color:var(--bt-ink);font:inherit;font-size:12px}
.bt-inbox-answer .bt-send,.bt-inbox-answer .bt-soft{height:28px;font-size:12px}
Expand Down Expand Up @@ -18866,6 +18941,10 @@ function apply(ctx) {
send: (sessionId, text) => after(call("send", { sessionId: partFor(sessionId), text })),
answerQuestion: (sessionId, questionId, answer) => after(call("answer-question", { sessionId: partFor(sessionId), questionId, answer })),
answerTaskInput: (id, questionId, answer) => after(call("task-answer-input", { id, questionId, answer })),
// The Done list is the acceptance surface (#401): a result is accepted, or sent back with a note
// that returns the same task to the Bot.
acceptTaskResult: (id) => after(call("task-accept-result", { id })),
sendBackTask: (id, note) => after(call("task-send-back", { id, note })),
dismissQuestion: (sessionId) => after(call("dismiss-question", { sessionId })),
answerApproval: (id, choice) => after(call("approval-answer", { id, choice })),
removeStandingApproval: (scope, id, kind) => after(call("standing-approval-remove", { scope, id, kind })),
Expand Down
6 changes: 6 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,12 @@ const ZH = {
'Latest: {text}': '最新:{text}',
'{count} tool calls': '{count} 次工具调用',
'Untitled task': '未命名任务',
// Accepting a result in Done (#401)
Accept: '接受',
'Send back': '退回',
'Send it back': '退回给 Bot',
'What should change': '需要改什么',
'Sent back: {note}': '已退回:{note}',
// Bot task board (#1058)
Tasks: '任务',
'Close tasks': '关闭任务',
Expand Down
60 changes: 59 additions & 1 deletion core/src/client/inbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,12 @@ export function inboxAnswer(actions, card, choice) {
if (text === '' || typeof questionId !== 'string') return undefined
return Promise.resolve(actions.answerTaskInput(card.task.id, questionId, text))
}
if (card?.kind === 'task' && card.status === 'result') {
if (choice === 'accept') return Promise.resolve(actions.acceptTaskResult(card.task.id))
const note = choice !== null && typeof choice === 'object' ? String(choice.note ?? '').trim() : ''
if (note === '') return undefined
return Promise.resolve(actions.sendBackTask(card.task.id, note))
}
if (card?.kind === 'approval' && card.approval?.answerable === true) {
return Promise.resolve(actions.answerApproval(card.approval.id, choice))
}
Expand All @@ -313,6 +319,45 @@ const answerSlot = (id) => {
return slot
}

// The Done list is the acceptance surface (#401): a result card is accepted here, or sent back with a
// note that returns the same task to the Bot. The box opens in place, and the note survives a roster poll
// because the slot is keyed by the card, like the answer slot above (#1140).
const resultSlots = new Map()
const resultSlot = (id) => {
let slot = resultSlots.get(id)
if (slot === undefined) {
slot = { open: false, text: '' }
resultSlots.set(id, slot)
}
return slot
}

function ResultActions({ card, busy, onAnswer }) {
const [, redraw] = useState(0)
if (!(card.kind === 'task' && card.status === 'result')) return null
const slot = resultSlot(card.id)
const send = () => {
const note = slot.text.trim()
if (busy || note === '') return
onAnswer(card, { note })
}
return h('span', { className: 'bt-inbox-result' },
slot.open ? h('input', {
type: 'text', className: 'bt-inbox-answer-text', value: slot.text, disabled: busy,
'aria-label': t('What should change'), placeholder: t('What should change'),
onChange: (event) => { slot.text = String(event.target?.value ?? ''); redraw(count => count + 1) },
onKeyDown: (event) => {
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
event.preventDefault()
send()
},
}) : null,
slot.open
? h('button', { type: 'button', className: 'bt-send bt-soft-sm', disabled: busy, onClick: send }, t('Send it back'))
: h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: () => { slot.open = true; redraw(count => count + 1) } }, t('Send back')),
h('button', { type: 'button', className: 'bt-send bt-soft-sm', disabled: busy, onClick: () => onAnswer(card, 'accept') }, t('Accept')))
}

// Options and a short answer, drawn inside the card so a click answers here.
function AnswerFields({ card, busy, onAnswer }) {
const [, redraw] = useState(0)
Expand Down Expand Up @@ -394,8 +439,11 @@ function whenText(card, now) {

export function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) {
const who = card.bot ? card.bot.name : card.room?.name ?? ''
const meta = [who, card.place, whenText(card, now)].filter(Boolean).join(' · ')
const produced = card.kind === 'task' && card.status === 'result' ? [card.bot?.model] : []
const meta = [who, ...produced, card.place, whenText(card, now)].filter(Boolean).join(' · ')
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 : undefined
return h('div', { className: 'bt-inbox-card', role: 'group', 'aria-label': `${t(STATUSES[card.status].label)}: ${card.title}`, 'data-status': card.status },
h('span', { className: 'bt-inbox-ava' }, card.bot ? h(BotAvatar, { bot: card.bot, size: 28, badge: false, live: false }) : card.room ? h(RoomAvatar, { room: card.room, roster, size: 28 }) : null),
h('span', { className: 'bt-inbox-copy' },
Expand All @@ -407,7 +455,12 @@ export function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen })
card.reason ? h('span', { className: 'bt-inbox-reason', title: card.reason }, card.reason) : null,
card.activity ? h('span', { className: 'bt-inbox-activity', title: card.activity }, t('Latest: {text}', { text: card.activity })) : null,
card.hint ? h('span', { className: 'bt-inbox-hint' }, card.hint) : null,
evidence.length > 0 ? h('span', { className: 'bt-inbox-evidence' }, ...evidence.map((href, index) => h('a', {
key: `${href}-${index}`, href, target: '_blank', rel: 'noreferrer noopener', className: 'bt-inbox-evidence-link', title: href,
}, href))) : null,
sentBack ? h('span', { className: 'bt-inbox-reason', title: sentBack }, t('Sent back: {note}', { note: sentBack })) : null,
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 @@ -523,6 +576,11 @@ export const INBOX_CSS = `
.bt-inbox-calls{font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
.bt-inbox-activity{color:var(--bt-ink-3)}
.bt-inbox-error{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere}
.bt-inbox-evidence{display:flex;flex-wrap:wrap;gap:6px;font-size:12px;line-height:16px}
.bt-inbox-evidence-link{color:var(--bt-blue,#3a7afe);text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.bt-inbox-evidence-link:hover{text-decoration:underline}
.bt-inbox-result{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px}
.bt-inbox-result .bt-send,.bt-inbox-result .bt-soft{height:28px;font-size:12px}
.bt-inbox-answer{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px}
.bt-inbox-answer-text{flex:1 1 140px;min-width:0;height:28px;padding:0 8px;border:.5px solid var(--bt-line-2);border-radius:8px;background:transparent;color:var(--bt-ink);font:inherit;font-size:12px}
.bt-inbox-answer .bt-send,.bt-inbox-answer .bt-soft{height:28px;font-size:12px}
Expand Down
4 changes: 4 additions & 0 deletions core/src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,10 @@ export function apply(ctx) {
send: (sessionId, text) => after(call('send', { sessionId: partFor(sessionId), text })),
answerQuestion: (sessionId, questionId, answer) => after(call('answer-question', { sessionId: partFor(sessionId), questionId, answer })),
answerTaskInput: (id, questionId, answer) => after(call('task-answer-input', { id, questionId, answer })),
// The Done list is the acceptance surface (#401): a result is accepted, or sent back with a note
// that returns the same task to the Bot.
acceptTaskResult: id => after(call('task-accept-result', { id })),
sendBackTask: (id, note) => after(call('task-send-back', { id, note })),
dismissQuestion: sessionId => after(call('dismiss-question', { sessionId })),
answerApproval: (id, choice) => after(call('approval-answer', { id, choice })),
removeStandingApproval: (scope, id, kind) => after(call('standing-approval-remove', { scope, id, kind })),
Expand Down
2 changes: 1 addition & 1 deletion core/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const SESSION_TARGETED = new Set(['earlier', 'send', 'answer-question', 'dismiss
// Task channel names. Requester checks live on the record in tasks.js; listing them
// here keeps a future `task-*` from auto-allowing for any signed-in actor (#111).
const TASK_ENDPOINTS = new Set([
'tasks', 'task-create', 'task-update', 'task-accept', 'task-start', 'task-return', 'task-accept-result', 'task-cancel',
'tasks', 'task-create', 'task-update', 'task-accept', 'task-start', 'task-return', 'task-accept-result', 'task-send-back', 'task-cancel',
'task-wait-input', 'task-answer-input',
])

Expand Down
1 change: 1 addition & 0 deletions core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,7 @@ export function install(rt) {
case 'task-start':
case 'task-return':
case 'task-accept-result':
case 'task-send-back':
case 'task-cancel':
case 'task-wait-input':
case 'task-answer-input':
Expand Down
33 changes: 31 additions & 2 deletions core/src/host/tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const TRANSITIONS = {
claimed: ['working', 'cancelled', 'failed'],
working: ['blocked', 'result-ready', 'cancelled', 'failed'],
blocked: ['working', 'cancelled', 'failed'],
'result-ready': ['accepted', 'cancelled'],
'result-ready': ['accepted', 'working', 'cancelled'],
accepted: [],
cancelled: [],
failed: [],
Expand Down Expand Up @@ -57,6 +57,21 @@ const readProgress = (value) => {
}
const oneLine = (value, max) => String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max)
const readTitle = value => oneLine(value, 300)
// Evidence a result carries (#401): links only, http(s), a handful, so a card can point at the diff, the
// file or the test run without the record becoming a transcript.
const LINKS_MAX = 5
const readLinks = (value) => {
if (value === undefined || value === null) return []
if (!Array.isArray(value)) throw invalid('Result links must be a list')
const links = []
for (const item of value) {
const link = oneLine(item, 300)
if (!/^https?:\/\//i.test(link) || links.includes(link)) continue
links.push(link)
if (links.length === LINKS_MAX) break
}
return links
}
const readNote = (value) => {
if (value === undefined || value === null) return undefined
return oneLine(value, 300)
Expand Down Expand Up @@ -594,8 +609,10 @@ export function install(rt) {
const task = find(body.id)
if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`)
requireRequester(task, actor)
// Read the evidence before the transition so a malformed write leaves the task where it is.
const links = readLinks(body.links)
transition(task, 'result-ready', actor)
task.result = { kind: 'summary', summary: String(body.summary ?? '').slice(0, 2000) }
task.result = { kind: 'summary', summary: String(body.summary ?? '').slice(0, 2000), ...(links.length ? { links } : {}) }
await save()
return view(task)
}
Expand Down Expand Up @@ -632,6 +649,18 @@ export function install(rt) {
await save()
return view(task)
}
case 'task-send-back': {
const task = find(body.id)
if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`)
requireRequester(task, actor)
if (task.status !== 'result-ready') throw stale('Only a result waiting for the requester can be sent back')
const note = oneLine(body.note, 500)
if (note === '') throw invalid('Send back needs a note for the Bot')
transition(task, 'working', actor, { by: 'sent back' })
task.followUp = { note, at: task.updatedAt }
await save()
return view(task)
}
case 'task-cancel': {
const task = find(body.id)
if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`)
Expand Down
Loading
Loading