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/2041-decision-open.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A decision row on the Project board now opens: activating a `decisions/*.md` row (by mouse or keyboard) shows that file's text under the row, and activating it again closes it ([#2041](https://github.com/ThinkFlowLab/FlyBot/issues/2041)) — before this, the board could list 200 decision files and open none of them. The text comes from a new host read that accepts only a name the board's own listing produced, including one past the 200-row cut, so a name like `decisions/../outside.md`, an absolute path, a name one level down or a symlink answers `not listed` without touching the disk; a file over 200,000 bytes answers `too large` rather than being cut, and a row whose file was deleted answers `absent` and stays a row.
57 changes: 50 additions & 7 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@ var ZH = {
"{from} → {to}": "{from} → {to}",
"+{count} more": "另有 {count} 条",
"{count} more not shown": "还有 {count} 条未显示",
"Reading the decision…": "正在读取决策…",
"not listed": "不在列表里",
delegated: "委托转达",
All: "全部",
Filter: "筛选",
Expand Down Expand Up @@ -10436,6 +10438,14 @@ var PROJECT_BOARD_CSS = `
.bt-project-decision .bt-project-date,.bt-project-meeting .bt-project-date{font-size:11px;opacity:.6;min-width:76px;font-variant-numeric:tabular-nums}
.bt-project-decision .bt-project-hash{margin-left:auto;font-size:11px;opacity:.55;font-family:var(--bt-mono,ui-monospace,monospace)}
.bt-project-decision .bt-project-title,.bt-project-meeting .bt-project-title{min-width:0}
/* #2041: the decision row became a button, so it keeps the row's look and drops the control's. */
button.bt-project-decision{width:100%;font:inherit;color:inherit;text-align:left;border:0;cursor:pointer}
button.bt-project-decision:hover{background:var(--bt-card-hover,rgba(127,127,127,.16))}
button.bt-project-decision:focus-visible{outline:2px solid var(--bt-accent,#4c8dff);outline-offset:1px}
button.bt-project-decision[data-open="true"]{outline:1px solid var(--bt-accent,#4c8dff)}
.bt-project-decision-wrap{margin:0}
.bt-project-decision-text{margin:0 0 8px;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));white-space:pre-wrap;overflow-wrap:anywhere;max-height:320px;overflow:auto;font-size:12px;font-family:var(--bt-mono,ui-monospace,monospace)}
.bt-project-decision-refusal{opacity:.75}
.bt-project-path{font-size:11px;opacity:.6;font-family:var(--bt-mono,ui-monospace,monospace)}
.bt-project-missing{font-size:11px;opacity:.6}
.bt-project-status{font-size:11px;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-left:auto}
Expand Down Expand Up @@ -10883,14 +10893,45 @@ function ProjectQueueGroups({ view, onOpen }) {
));
}
var countText = (value) => value === null ? t("unknown") : String(value);
function ProjectBoardRecord({ record }) {
function ProjectBoardRecord({ record, projectId, call: call4 }) {
const { decisions, meetings } = record;
const [open, setOpen] = (0, import_react19.useState)(null);
const toggle = (path) => {
if (open !== null && open.path === path) {
setOpen(null);
return;
}
if (typeof call4 !== "function") return;
setOpen({ path, answer: null });
const settle = (answer) => setOpen((current) => current !== null && current.path === path ? { path, answer } : current);
call4("project-decision", { id: projectId, path }).then(settle, (error) => settle({ ok: false, reason: String(error?.message ?? error) }));
};
const decisionBody = (file) => {
if (open === null || open.path !== file.path) return null;
const answer = open.answer;
if (answer === null) return (0, import_react19.createElement)("div", { className: "bt-project-decision-text", "data-open-decision": file.path }, t("Reading the decision…"));
if (answer.ok !== true) return (0, import_react19.createElement)("div", { className: "bt-project-decision-text bt-project-decision-refusal", "data-open-decision": file.path, "data-refused": "true" }, text(answer.reason) || t("not listed"));
return (0, import_react19.createElement)("pre", { className: "bt-project-decision-text", "data-open-decision": file.path }, typeof answer.text === "string" ? answer.text : "");
};
const decisionRows = decisions.files.map((file) => (0, import_react19.createElement)(
"div",
{ key: file.key, className: "bt-project-decision", "data-path": file.path, "data-sha256": file.sha256 },
(0, import_react19.createElement)("span", { className: "bt-project-date" }, file.date),
(0, import_react19.createElement)("span", { className: "bt-project-title" }, file.title === "" ? file.name : file.title),
(0, import_react19.createElement)("span", { className: "bt-project-hash", title: file.sha256 }, file.shortHash)
{ key: file.key, className: "bt-project-decision-wrap" },
(0, import_react19.createElement)(
"button",
{
type: "button",
className: "bt-project-decision",
"data-path": file.path,
"data-sha256": file.sha256,
"data-open": open !== null && open.path === file.path ? "true" : void 0,
"aria-expanded": open !== null && open.path === file.path ? "true" : "false",
onClick: () => toggle(file.path)
},
(0, import_react19.createElement)("span", { className: "bt-project-date" }, file.date),
(0, import_react19.createElement)("span", { className: "bt-project-title" }, file.title === "" ? file.name : file.title),
(0, import_react19.createElement)("span", { className: "bt-project-hash", title: file.sha256 }, file.shortHash)
),
decisionBody(file)
));
const recipientText = (list) => list.length === 0 ? null : list.map((name) => `@${name}`).join(", ");
const proxyText = (meeting) => meeting.participants.filter((participant) => participant.speaksFor !== null).map((participant) => `${participant.name} ${t("for {user}", { user: participant.speaksFor })}`).join(", ");
Expand Down Expand Up @@ -11133,7 +11174,7 @@ function ProjectAgentEntry({ projectId, read, onOpen } = {}) {
onClick: open
}, t("My agent"));
}
function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot, onOpenDossier }) {
function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot, onOpenDossier, call: call4 }) {
const view = projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics });
return (0, import_react19.createElement)(
"div",
Expand Down Expand Up @@ -11163,7 +11204,7 @@ function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, me
(0, import_react19.createElement)(ProjectBoardRows, { view, onOpen }),
(0, import_react19.createElement)(ProjectQueueGroups, { view, onOpen }),
(0, import_react19.createElement)(ProjectDoneGroup, { view, onOpen }),
(0, import_react19.createElement)(ProjectBoardRecord, { record: view.record })
(0, import_react19.createElement)(ProjectBoardRecord, { record: view.record, projectId: view.projectId, call: call4 })
);
}
function ProjectSettingsPanel({ project, assistantMine, call: request, onClose, onSaved } = {}) {
Expand Down Expand Up @@ -11465,6 +11506,8 @@ function ProjectBoardOverlay({ actions, call: request } = {}) {
queues: state.queues,
metrics: state.metrics,
team: state.team,
// #2041: a decision row opens through the same channel read the page polls with.
call: read,
onSettings: () => setPanel("settings"),
onMembers: () => setPanel("members"),
// #1864: a team row's Dossier control opens the read-only panel, fed by the same read.
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 @@ -226,6 +226,8 @@ const ZH = {
'{from} → {to}': '{from} → {to}',
'+{count} more': '另有 {count} 条',
'{count} more not shown': '还有 {count} 条未显示',
'Reading the decision…': '正在读取决策…',
'not listed': '不在列表里',
delegated: '委托转达',
All: '全部',
Filter: '筛选',
Expand Down
52 changes: 47 additions & 5 deletions core/src/client/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ export const PROJECT_BOARD_CSS = `
.bt-project-decision .bt-project-date,.bt-project-meeting .bt-project-date{font-size:11px;opacity:.6;min-width:76px;font-variant-numeric:tabular-nums}
.bt-project-decision .bt-project-hash{margin-left:auto;font-size:11px;opacity:.55;font-family:var(--bt-mono,ui-monospace,monospace)}
.bt-project-decision .bt-project-title,.bt-project-meeting .bt-project-title{min-width:0}
/* #2041: the decision row became a button, so it keeps the row's look and drops the control's. */
button.bt-project-decision{width:100%;font:inherit;color:inherit;text-align:left;border:0;cursor:pointer}
button.bt-project-decision:hover{background:var(--bt-card-hover,rgba(127,127,127,.16))}
button.bt-project-decision:focus-visible{outline:2px solid var(--bt-accent,#4c8dff);outline-offset:1px}
button.bt-project-decision[data-open="true"]{outline:1px solid var(--bt-accent,#4c8dff)}
.bt-project-decision-wrap{margin:0}
.bt-project-decision-text{margin:0 0 8px;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));white-space:pre-wrap;overflow-wrap:anywhere;max-height:320px;overflow:auto;font-size:12px;font-family:var(--bt-mono,ui-monospace,monospace)}
.bt-project-decision-refusal{opacity:.75}
.bt-project-path{font-size:11px;opacity:.6;font-family:var(--bt-mono,ui-monospace,monospace)}
.bt-project-missing{font-size:11px;opacity:.6}
.bt-project-status{font-size:11px;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-left:auto}
Expand Down Expand Up @@ -529,12 +537,44 @@ const countText = value => (value === null ? t('unknown') : String(value))
// The record beside the cards (#1767): decisions first, then the meetings that reached one, and
// a meeting still open or blocked listed apart — never as a decision. Every row is text the host
// already answered; the only thing this reads again is the hash it was handed.
function ProjectBoardRecord({ record }) {
// #2041: a decision row opens. The row is a button, so it is reachable with a keyboard and by
// activation; one row is open at a time, and the text under it is the host's answer to that one
// name — the board's own listing, never a second read of the folder.
function ProjectBoardRecord({ record, projectId, call }) {
const { decisions, meetings } = record
const decisionRows = decisions.files.map(file => h('div', { key: file.key, className: 'bt-project-decision', 'data-path': file.path, 'data-sha256': file.sha256 },
const [open, setOpen] = useState(null)
const toggle = (path) => {
if (open !== null && open.path === path) { setOpen(null); return }
if (typeof call !== 'function') return
setOpen({ path, answer: null })
// An answer that lands after another row was opened must not paint this one: it is kept only
// while its own row is the open one.
const settle = (answer) => setOpen(current => (current !== null && current.path === path ? { path, answer } : current))
call('project-decision', { id: projectId, path }).then(settle, error => settle({ ok: false, reason: String(error?.message ?? error) }))
}
const decisionBody = (file) => {
if (open === null || open.path !== file.path) return null
const answer = open.answer
if (answer === null) return h('div', { className: 'bt-project-decision-text', 'data-open-decision': file.path }, t('Reading the decision…'))
// `absent` and `too large` read as the sentence the host sent, the same words `project_state`
// reports them in; the text of a decision is drawn as text, never as markup it contains.
if (answer.ok !== true) return h('div', { className: 'bt-project-decision-text bt-project-decision-refusal', 'data-open-decision': file.path, 'data-refused': 'true' }, text(answer.reason) || t('not listed'))
return h('pre', { className: 'bt-project-decision-text', 'data-open-decision': file.path }, typeof answer.text === 'string' ? answer.text : '')
}
const decisionRows = decisions.files.map(file => h('div', { key: file.key, className: 'bt-project-decision-wrap' },
h('button', {
type: 'button',
className: 'bt-project-decision',
'data-path': file.path,
'data-sha256': file.sha256,
'data-open': open !== null && open.path === file.path ? 'true' : undefined,
'aria-expanded': open !== null && open.path === file.path ? 'true' : 'false',
onClick: () => toggle(file.path),
},
h('span', { className: 'bt-project-date' }, file.date),
h('span', { className: 'bt-project-title' }, file.title === '' ? file.name : file.title),
h('span', { className: 'bt-project-hash', title: file.sha256 }, file.shortHash)))
h('span', { className: 'bt-project-hash', title: file.sha256 }, file.shortHash)),
decisionBody(file)))
const recipientText = list => (list.length === 0 ? null : list.map(name => `@${name}`).join(', '))
// A proxy line reads in the same words a task row already uses for a proxy holder.
const proxyText = meeting => meeting.participants
Expand Down Expand Up @@ -763,7 +803,7 @@ export function ProjectAgentEntry({ projectId, read, onOpen } = {}) {
}, t('My agent'))
}

export function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot, onOpenDossier }) {
export function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot, onOpenDossier, call }) {
const view = projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics })
return h('div', { className: 'bt-project-board', 'data-can-manage': view.canManage ? 'true' : 'false' },
h('h3', null, view.title),
Expand All @@ -785,7 +825,7 @@ export function ProjectBoardPane({ project, rows, done, canManage, tasks, decisi
h(ProjectBoardRows, { view, onOpen }),
h(ProjectQueueGroups, { view, onOpen }),
h(ProjectDoneGroup, { view, onOpen }),
h(ProjectBoardRecord, { record: view.record }))
h(ProjectBoardRecord, { record: view.record, projectId: view.projectId, call }))
}

// #1749: the settings panel. It writes through the three paths the host already had — bind or
Expand Down Expand Up @@ -1079,6 +1119,8 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) {
: h(ProjectBoardPane, {
project: state.project, rows: state.rows, done: state.done, canManage: state.canManage, tasks: state.tasks,
decisions: state.decisions, meetings: state.meetings, queues: state.queues, metrics: state.metrics, team: state.team,
// #2041: a decision row opens through the same channel read the page polls with.
call: read,
onSettings: () => setPanel('settings'),
onMembers: () => setPanel('members'),
// #1864: a team row's Dossier control opens the read-only panel, fed by the same read.
Expand Down
3 changes: 3 additions & 0 deletions core/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ const TASK_ENDPOINTS = new Set([
// other wiring point was present, and a signed-in host answered `bot/not-found: Unknown
// endpoint project-board` while `project-read` and `project-of` answered ALLOW.
'projects', 'project-create', 'project-read', 'project-bind', 'project-of', 'project-board',
// #2041: the text of one decision row. It reads the same Project through the same listing the
// board just produced, so it sits beside `project-board` rather than behind a new gate.
'project-decision',
// The team dossier (#1864) is a read over the same Project and the same bindings: the owner
// decided on 2026-10-30 that persona and memory are shared by the Project's members, so it sits
// beside `project-read` rather than behind the owner gate.
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 @@ -675,6 +675,7 @@ export function install(rt) {
case 'project-bind':
case 'project-of':
case 'project-board':
case 'project-decision':
case 'project-team':
case 'project-role':
case 'project-delete':
Expand Down
Loading
Loading