diff --git a/core/client.js b/core/client.js index 643360be..bb5f6f5b 100644 --- a/core/client.js +++ b/core/client.js @@ -1356,7 +1356,11 @@ var ZH = { "Some folders are not shown.": "还有一些文件夹没有显示。", "Project settings": "项目设置", "Project board": "项目看板", - "No Project here.": "这里没有项目。" + "No Project here.": "这里没有项目。", + // The Project board names its columns with the same keys the task record uses. + "not-started": "未开始", + "in-progress": "进行中", + "done": "已完成" }; var HOST_ERRORS = [ [/^Model (.+) is not available\. Available: (.*)$/, "模型 $1 不可用。可用的:$2"], @@ -8920,6 +8924,16 @@ function KanbanDialog({ roster, actions, leaving = false }) { const boardTasks = boardTasksFor(tasks, archive); const close = (0, import_react15.useCallback)(() => actions.closeBoard(), []); useEscape(close); + (0, import_react15.useEffect)(() => { + if (typeof window === "undefined") return; + if (!window.location.hash.startsWith("#task=")) return; + const [kind, id] = window.location.hash.slice("#task=".length).split(":"); + window.location.hash = ""; + if (id === void 0 || id === "") return; + if (kind === "bot" && roster.byId?.[id]) setScope({ kind: "bot", botId: id }); + if (kind === "group" && roster.roomsById?.[id]) setScope({ kind: "group", roomId: id }); + }, []); + const projectId = scope?.kind === "bot" ? roster.byId?.[scope.botId]?.projectId : scope?.kind === "group" ? roster.roomsById?.[scope.roomId]?.projectId : void 0; const counts2 = {}; for (const task of [...own, ...archive]) { const id = task.ownerBotId; @@ -8962,6 +8976,17 @@ function KanbanDialog({ roster, actions, leaving = false }) { { className: "bt-mem-head" }, (0, import_react15.createElement)("h2", { id: "bt-board-heading" }, t("Board")), (0, import_react15.createElement)("span", { className: "bt-mem-status" }, name), + // #1701: the board the Bot or group belongs to opens from here; nothing else writes the + // Project hash, so without this the Project board was reachable only by editing the URL. + projectId ? (0, import_react15.createElement)("button", { + type: "button", + className: "bt-soft bt-soft-sm", + "data-open-project": projectId, + onClick: () => { + window.location.hash = `#project=${projectId}`; + close(); + } + }, t("Project board")) : null, (0, import_react15.createElement)("span", { className: "bt-mem-head-gap" }), (0, import_react15.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close board"), autoFocus: true, onClick: close }, (0, import_react15.createElement)(CloseIcon)) ), @@ -19602,19 +19627,38 @@ var PROJECT_BOARD_CSS = ` var PROJECT_SOURCES = ["bot", "group", "workflow"]; var SOURCE_LABELS = { bot: "Bots", group: "Groups", workflow: "Workflows" }; var text = (value) => typeof value === "string" ? value.trim() : ""; -function projectBoardView({ project, rows, canManage } = {}) { +var boardOfRow = (row, task) => { + if (task === void 0) return null; + if (row.source === "group") { + const id = text(task.origin?.roomId); + return id === "" ? null : { kind: "group", id }; + } + if (row.source === "bot") { + const id = text(task.ownerBotId); + return id === "" ? null : { kind: "bot", id }; + } + return null; +}; +function projectBoardView({ project, rows, canManage, tasks } = {}) { const list = Array.isArray(rows) ? rows.filter((row) => row !== null && typeof row === "object" && text(row.id) !== "") : []; + const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task])); const groups = PROJECT_SOURCES.map((source) => ({ source, label: SOURCE_LABELS[source], - rows: list.filter((row) => row.source === source).map((row) => ({ - id: text(row.id), - key: `task:${text(row.id)}`, - column: text(row.column) || "not-started", - assignee: text(row.assignee) || null, - runId: text(row.runId) || null, - nodeId: text(row.nodeId) || null - })) + rows: list.filter((row) => row.source === source).map((row) => { + const task = byId.get(text(row.id)); + return { + id: text(row.id), + key: `task:${text(row.id)}`, + column: text(row.column) || "not-started", + 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, + board: boardOfRow(row, task) + }; + }) })).filter((group) => group.rows.length > 0); return { projectId: text(project?.id) || null, @@ -19632,28 +19676,33 @@ function ProjectBoardRows({ view, onOpen }) { "section", { key: group.source, className: "bt-project-group", "data-source": group.source }, (0, import_react46.createElement)("h4", null, t(group.label)), - group.rows.map((row) => (0, import_react46.createElement)( - "div", - { - key: row.key, - className: "bt-project-row", - "data-task-id": row.id, - "data-column": row.column, - role: "button", - tabIndex: 0, - onClick: () => onOpen?.(row.id), - onKeyDown: (event) => { - if (event.key === "Enter" || event.key === " ") onOpen?.(row.id); - } - }, - (0, import_react46.createElement)("span", { className: "bt-project-column" }, t(row.column)), - (0, import_react46.createElement)("span", { className: "bt-project-title" }, row.assignee ? `@${row.assignee}` : "—"), - row.runId === null ? null : (0, import_react46.createElement)("span", { className: "bt-project-ref" }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`) - )) + group.rows.map((row) => { + const open = row.board === null ? void 0 : () => onOpen?.(row.board); + return (0, import_react46.createElement)( + "div", + { + key: row.key, + className: "bt-project-row", + "data-task-id": row.id, + "data-column": row.column, + "data-priority": row.priority ?? void 0, + role: open === void 0 ? void 0 : "button", + tabIndex: open === void 0 ? void 0 : 0, + onClick: open, + onKeyDown: open === void 0 ? void 0 : (event) => { + if (event.key === "Enter" || event.key === " ") open(); + } + }, + (0, import_react46.createElement)("span", { className: "bt-project-column" }, t(row.column)), + (0, import_react46.createElement)("span", { className: "bt-project-title" }, row.title ?? (row.assignee ? `@${row.assignee}` : "—")), + row.priority === null ? null : (0, import_react46.createElement)("span", { className: "bt-project-priority" }, row.priority), + row.runId === null ? null : (0, import_react46.createElement)("span", { className: "bt-project-ref" }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`) + ); + }) )); } -function ProjectBoardPane({ project, rows, canManage, onOpen }) { - const view = projectBoardView({ project, rows, canManage }); +function ProjectBoardPane({ project, rows, canManage, tasks, onOpen }) { + const view = projectBoardView({ project, rows, canManage, tasks }); return (0, import_react46.createElement)( "div", { className: "bt-project-board", "data-can-manage": view.canManage ? "true" : "false" }, @@ -19663,16 +19712,27 @@ function ProjectBoardPane({ project, rows, canManage, onOpen }) { (0, import_react46.createElement)(ProjectBoardRows, { view, onOpen }) ); } -function ProjectBoardOverlay() { +function ProjectBoardOverlay({ actions } = {}) { const [state, setState] = (0, import_react46.useState)(null); + const [hash, setHash] = (0, import_react46.useState)(() => typeof window === "undefined" ? "" : window.location.hash); const close = (0, import_react46.useCallback)(() => { if (typeof window !== "undefined") window.location.hash = ""; setState(null); }, []); (0, import_react46.useEffect)(() => { - if (typeof window === "undefined") return; - if (!window.location.hash.startsWith("#project")) return; - const wanted = window.location.hash.slice("#project".length).replace(/^=/, ""); + if (typeof window === "undefined") return void 0; + const sync = () => setHash(window.location.hash); + window.addEventListener("hashchange", sync); + sync(); + return () => window.removeEventListener("hashchange", sync); + }, []); + (0, import_react46.useEffect)(() => { + if (typeof window === "undefined") return void 0; + if (!hash.startsWith("#project")) { + setState(null); + return void 0; + } + const wanted = hash.slice("#project".length).replace(/^=/, ""); let live = true; void (async () => { try { @@ -19680,27 +19740,42 @@ function ProjectBoardOverlay() { const projects = listing?.projects ?? []; const project = wanted === "" ? projects[0] : projects.find((item) => item.id === wanted); if (project === void 0) { - if (live) setState({ project: null, rows: [], canManage: false }); + if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }); return; } - const [read, board] = await Promise.all([call("project-read", { id: project.id }), call("project-board", { id: project.id })]); - if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true }); + const [read, board, taskList] = await Promise.all([ + call("project-read", { id: project.id }), + call("project-board", { id: project.id }), + call("tasks", {}) + ]); + const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? []; + if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true, tasks }); } catch { - if (live) setState({ project: null, rows: [], canManage: false }); + if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }); } })(); return () => { live = false; }; - }, []); + }, [hash]); if (state === null) return null; return (0, import_react46.createElement)( "div", { className: "bt-overlay bt-project-overlay", role: "dialog", "aria-label": t("Project board") }, (0, import_react46.createElement)("button", { type: "button", className: "bt-project-close", onClick: close }, t("Close")), - state.project === null ? (0, import_react46.createElement)("div", { className: "bt-project-empty" }, t("No Project here.")) : (0, import_react46.createElement)(ProjectBoardPane, { project: state.project, rows: state.rows, canManage: state.canManage, onOpen: (id) => { - window.location.hash = `#task=${id}`; - } }) + state.project === null ? (0, import_react46.createElement)("div", { className: "bt-project-empty" }, t("No Project here.")) : (0, import_react46.createElement)(ProjectBoardPane, { + project: state.project, + rows: state.rows, + canManage: state.canManage, + tasks: state.tasks, + // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and + // lands on that Bot or group. Nothing here re-decides which board that is. + onOpen: (board) => { + if (typeof window !== "undefined") window.location.hash = `#task=${board.kind}:${board.id}`; + actions?.openBoard?.(); + setState(null); + } + }) ); } @@ -20973,7 +21048,8 @@ function apply(ctx) { shadow("shell.overlay", { id: "bot-signin", order: 55, inject: face }, DeepSeekSignIn), shadow("shell.overlay", { id: "bot-toast", order: 60, inject: face }, ErrorToast), shadow("shell.overlay", { id: "flybot-approvals", order: 58, inject: face }, FlyBotApprovals), - shadow("shell.overlay", { id: "project-board", order: 57 }, ProjectBoardOverlay) + // The board's rows open the Tasks board a task lives on, which only `actions` can do (#1701). + shadow("shell.overlay", { id: "project-board", order: 57, inject: face }, ProjectBoardOverlay) ]; const installConversation = () => [ installCompiledStyles(CONVERSATION_CSS, "conversation"), diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 8828e659..79f89ab7 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -1310,6 +1310,10 @@ const ZH = { 'Project settings': '项目设置', 'Project board': '项目看板', 'No Project here.': '这里没有项目。', + // The Project board names its columns with the same keys the task record uses. + 'not-started': '未开始', + 'in-progress': '进行中', + 'done': '已完成', } // Host errors stay English, since Bots read them too; they are translated where the diff --git a/core/src/client/index.js b/core/src/client/index.js index 399c7db5..cd7590d7 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -911,7 +911,8 @@ export function apply(ctx) { shadow('shell.overlay', { id: 'bot-signin', order: 55, inject: face }, DeepSeekSignIn), shadow('shell.overlay', { id: 'bot-toast', order: 60, inject: face }, ErrorToast), shadow('shell.overlay', { id: 'flybot-approvals', order: 58, inject: face }, FlyBotApprovals), - shadow('shell.overlay', { id: 'project-board', order: 57 }, ProjectBoardOverlay), + // The board's rows open the Tasks board a task lives on, which only `actions` can do (#1701). + shadow('shell.overlay', { id: 'project-board', order: 57, inject: face }, ProjectBoardOverlay), ] const installConversation = () => [ installCompiledStyles(CONVERSATION_CSS, 'conversation'), diff --git a/core/src/client/kanban.js b/core/src/client/kanban.js index d3dd00e3..d8d0d134 100644 --- a/core/src/client/kanban.js +++ b/core/src/client/kanban.js @@ -368,6 +368,21 @@ export function KanbanDialog({ roster, actions, leaving = false }) { const boardTasks = boardTasksFor(tasks, archive) const close = useCallback(() => actions.closeBoard(), []) useEscape(close) + // #1701: a Project board row opens the board its task lives on by naming it here, so the id a + // row carries is read rather than written into a hash nothing consumed. + useEffect(() => { + if (typeof window === 'undefined') return + if (!window.location.hash.startsWith('#task=')) return + const [kind, id] = window.location.hash.slice('#task='.length).split(':') + window.location.hash = '' + if (id === undefined || id === '') return + if (kind === 'bot' && roster.byId?.[id]) setScope({ kind: 'bot', botId: id }) + if (kind === 'group' && roster.roomsById?.[id]) setScope({ kind: 'group', roomId: id }) + }, []) + // The Project this board belongs to, when its Bot or group is bound to one. + const projectId = scope?.kind === 'bot' + ? roster.byId?.[scope.botId]?.projectId + : scope?.kind === 'group' ? roster.roomsById?.[scope.roomId]?.projectId : undefined const counts = {} for (const task of [...own, ...archive]) { const id = task.ownerBotId @@ -404,6 +419,12 @@ export function KanbanDialog({ roster, actions, leaving = false }) { h('div', { className: 'bt-mem-head' }, h('h2', { id: 'bt-board-heading' }, t('Board')), h('span', { className: 'bt-mem-status' }, name), + // #1701: the board the Bot or group belongs to opens from here; nothing else writes the + // Project hash, so without this the Project board was reachable only by editing the URL. + projectId ? h('button', { + type: 'button', className: 'bt-soft bt-soft-sm', 'data-open-project': projectId, + onClick: () => { window.location.hash = `#project=${projectId}`; close() }, + }, t('Project board')) : null, h('span', { className: 'bt-mem-head-gap' }), h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close board'), autoFocus: true, onClick: close }, h(CloseIcon))), h(BoardView, { diff --git a/core/src/client/project-board.js b/core/src/client/project-board.js index 192f6fc1..f2e1aa94 100644 --- a/core/src/client/project-board.js +++ b/core/src/client/project-board.js @@ -23,24 +23,50 @@ export const SOURCE_LABELS = { bot: 'Bots', group: 'Groups', workflow: 'Workflow const text = value => (typeof value === 'string' ? value.trim() : '') +// 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. +const boardOfRow = (row, task) => { + if (task === undefined) return null + if (row.source === 'group') { + const id = text(task.origin?.roomId) + return id === '' ? null : { kind: 'group', id } + } + if (row.source === 'bot') { + const id = text(task.ownerBotId) + return id === '' ? null : { kind: 'bot', id } + } + return null +} + // 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. -export function projectBoardView({ project, rows, canManage } = {}) { +// `tasks` is the one task read joined by id: the row carries an id and who holds it, not its text. +export function projectBoardView({ project, rows, canManage, tasks } = {}) { const list = Array.isArray(rows) ? rows.filter(row => row !== null && typeof row === 'object' && text(row.id) !== '') : [] + const byId = new Map((Array.isArray(tasks) ? tasks : []) + .filter(task => task !== null && typeof task === 'object' && text(task.id) !== '') + .map(task => [text(task.id), task])) const groups = PROJECT_SOURCES .map(source => ({ source, label: SOURCE_LABELS[source], rows: list .filter(row => row.source === source) - .map(row => ({ - id: text(row.id), - key: `task:${text(row.id)}`, - column: text(row.column) || 'not-started', - assignee: text(row.assignee) || null, - runId: text(row.runId) || null, - nodeId: text(row.nodeId) || null, - })), + .map((row) => { + const task = byId.get(text(row.id)) + return { + id: text(row.id), + key: `task:${text(row.id)}`, + column: text(row.column) || 'not-started', + 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, + board: boardOfRow(row, task), + } + }), })) .filter(group => group.rows.length > 0) return { @@ -59,23 +85,28 @@ function ProjectBoardRows({ view, onOpen }) { if (view.empty) return h('div', { className: 'bt-project-empty' }, t(view.emptyText)) return view.groups.map(group => h('section', { key: group.source, className: 'bt-project-group', 'data-source': group.source }, h('h4', null, t(group.label)), - group.rows.map(row => h('div', { - key: row.key, - className: 'bt-project-row', - 'data-task-id': row.id, - 'data-column': row.column, - role: 'button', - tabIndex: 0, - onClick: () => onOpen?.(row.id), - onKeyDown: event => { if (event.key === 'Enter' || event.key === ' ') onOpen?.(row.id) }, - }, - h('span', { className: 'bt-project-column' }, t(row.column)), - h('span', { className: 'bt-project-title' }, row.assignee ? `@${row.assignee}` : '—'), - row.runId === null ? null : h('span', { className: 'bt-project-ref' }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`))))) + group.rows.map((row) => { + const open = row.board === null ? undefined : () => onOpen?.(row.board) + return h('div', { + key: row.key, + className: 'bt-project-row', + 'data-task-id': row.id, + 'data-column': row.column, + 'data-priority': row.priority ?? undefined, + role: open === undefined ? undefined : 'button', + tabIndex: open === undefined ? undefined : 0, + onClick: open, + onKeyDown: open === undefined ? undefined : event => { if (event.key === 'Enter' || event.key === ' ') open() }, + }, + h('span', { className: 'bt-project-column' }, t(row.column)), + h('span', { className: 'bt-project-title' }, row.title ?? (row.assignee ? `@${row.assignee}` : '—')), + row.priority === null ? null : h('span', { className: 'bt-project-priority' }, row.priority), + row.runId === null ? null : h('span', { className: 'bt-project-ref' }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`)) + }))) } -export function ProjectBoardPane({ project, rows, canManage, onOpen }) { - const view = projectBoardView({ project, rows, canManage }) +export function ProjectBoardPane({ project, rows, canManage, tasks, onOpen }) { + const view = projectBoardView({ project, rows, canManage, tasks }) return h('div', { className: 'bt-project-board', 'data-can-manage': view.canManage ? 'true' : 'false' }, h('h3', null, view.title), // Management affordances are drawn only for the owner; the refusal itself is the host's. @@ -85,33 +116,61 @@ export function ProjectBoardPane({ project, rows, canManage, onOpen }) { // The pane entry point (#1710): `#project=` opens one Project's board, `#project` the first. // Reading is a POST through the same host channel every other view uses. -export function ProjectBoardOverlay() { +export function ProjectBoardOverlay({ actions } = {}) { const [state, setState] = useState(null) + // The hash is the whole entry point, so it has to be read again every time it changes: an + // entry that writes `#project=` while this mounts opened nothing when the read was one-shot. + const [hash, setHash] = useState(() => (typeof window === 'undefined' ? '' : window.location.hash)) const close = useCallback(() => { if (typeof window !== 'undefined') window.location.hash = '' setState(null) }, []) useEffect(() => { - if (typeof window === 'undefined') return - if (!window.location.hash.startsWith('#project')) return - const wanted = window.location.hash.slice('#project'.length).replace(/^=/, '') + if (typeof window === 'undefined') return undefined + const sync = () => setHash(window.location.hash) + window.addEventListener('hashchange', sync) + sync() + return () => window.removeEventListener('hashchange', sync) + }, []) + useEffect(() => { + if (typeof window === 'undefined') return undefined + if (!hash.startsWith('#project')) { setState(null); return undefined } + const wanted = hash.slice('#project'.length).replace(/^=/, '') let live = true void (async () => { try { const listing = await call('projects') const projects = listing?.projects ?? [] const project = wanted === '' ? projects[0] : projects.find(item => item.id === wanted) - if (project === undefined) { if (live) setState({ project: null, rows: [], canManage: false }); return } - const [read, board] = await Promise.all([call('project-read', { id: project.id }), call('project-board', { id: project.id })]) - if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true }) - } catch { if (live) setState({ project: null, rows: [], canManage: false }) } + if (project === undefined) { if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }); return } + // Four reads once, none polling: the Project, its record, its rows, and the task texts the + // rows are joined against. Nothing here asks a Bot by Bot. + const [read, board, taskList] = await Promise.all([ + call('project-read', { id: project.id }), + call('project-board', { id: project.id }), + call('tasks', {}), + ]) + const tasks = Array.isArray(taskList) ? taskList : (taskList?.tasks ?? []) + if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true, tasks }) + } catch { if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }) } })() return () => { live = false } - }, []) + }, [hash]) if (state === null) return null return h('div', { className: 'bt-overlay bt-project-overlay', role: 'dialog', 'aria-label': t('Project board') }, h('button', { type: 'button', className: 'bt-project-close', onClick: close }, t('Close')), state.project === null ? h('div', { className: 'bt-project-empty' }, t('No Project here.')) - : h(ProjectBoardPane, { project: state.project, rows: state.rows, canManage: state.canManage, onOpen: id => { window.location.hash = `#task=${id}` } })) + // A row opens the board its task lives on, carried by `#task=`: the Tasks board reads + // that hash and lands on the Bot or group the task belongs to. + : h(ProjectBoardPane, { + project: state.project, rows: state.rows, canManage: state.canManage, tasks: state.tasks, + // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and + // lands on that Bot or group. Nothing here re-decides which board that is. + onOpen: (board) => { + if (typeof window !== 'undefined') window.location.hash = `#task=${board.kind}:${board.id}` + actions?.openBoard?.() + setState(null) + }, + })) } diff --git a/core/test/project-board-entry.test.js b/core/test/project-board-entry.test.js new file mode 100644 index 00000000..3b05a96c --- /dev/null +++ b/core/test/project-board-entry.test.js @@ -0,0 +1,157 @@ +// Project board, the reachable slice (#1701): #1715 shipped the board itself, but nothing writes +// `#project=`, a row only repeats `@assignee`, and clicking one sets a hash no view reads. +// These tests pin the three surfaces a reader needs — an entry that opens the board, a row that +// says which task it is, and a row that opens the board the task lives on — plus the three column +// labels the board already asks `t()` for. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const states = [] +const effects = [] +const setters = [] + +const load = async (entry) => { + const bundle = await build({ + entryPoints: [fileURLToPath(new URL(`../src/client/${entry}`, import.meta.url))], + bundle: true, format: 'cjs', platform: 'node', write: false, + external: ['react', 'react-dom'], + }) + const module = { exports: {} } + const sandbox = { + module, exports: module.exports, setTimeout, clearTimeout, + setInterval: () => 0, clearInterval: () => {}, Date, Promise, console, + window: { location: { hash: '' }, addEventListener: () => {}, removeEventListener: () => {} }, + require: (name) => { + const platform = { + react: { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useState: (initial) => { + const value = states.length > 0 ? states.shift() : (typeof initial === 'function' ? initial() : initial) + return [value, (next) => { setters.push(typeof next === 'function' ? next(value) : next) }] + }, + useEffect: fn => { effects.push(fn) }, + useLayoutEffect: fn => { effects.push(fn) }, + useCallback: fn => fn, + useMemo: fn => fn(), + useRef: current => ({ current }), + }, + } + if (!(name in platform)) throw new Error(`no platform module ${name}`) + return platform[name] + }, + } + runInNewContext(bundle.outputFiles[0].text, sandbox) + return { ...module.exports, window: sandbox.window } +} + +const expand = (node) => { + if (Array.isArray(node)) return node.flatMap(expand) + if (node === null || typeof node !== 'object') return node + if (typeof node.type === 'function') 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('') : '') +// Arrays and objects minted inside the vm have another realm's prototype, so compare copies. +const plain = value => (value === undefined ? value : JSON.parse(JSON.stringify(value))) + +const roster = () => { + const view = { + self: { memberId: 'm1' }, + bots: [ + { id: 'a', name: 'Chief', ownerId: 'm1', createdAt: 1, projectId: 'project-rocket' }, + { id: 'b', name: 'Loose', ownerId: 'm1', createdAt: 2 }, + ], + rooms: [ + { id: 'r1', name: 'Ops', admin: 'a', projectId: 'project-rocket', participants: [{ kind: 'human', id: 'm1', role: 'member' }] }, + { id: 'r2', name: 'Garden', admin: 'a', participants: [{ kind: 'human', id: 'm1', role: 'member' }] }, + ], + } + view.byId = Object.fromEntries(view.bots.map(bot => [bot.id, bot])) + view.roomsById = Object.fromEntries(view.rooms.map(room => [room.id, room])) + return view +} + +// The rows #1715's host read model returns: an id, a source, a column and who holds it — no text. +const rows = [ + { id: 'task-b1', source: 'bot', column: 'in-progress', assignee: 'Dev4', updatedAt: 3 }, + { id: 'task-g1', source: 'group', column: 'done', assignee: 'Chief', updatedAt: 2 }, + { id: 'task-w1', source: 'workflow', column: 'not-started', assignee: null, runId: 'run-7', nodeId: 'build', updatedAt: 1 }, +] +// The task records the board joins by id, so the card can say what the row is about. +const taskRecords = [ + { id: 'task-b1', title: 'Ship the board', priority: 'high', ownerBotId: 'a' }, + { id: 'task-g1', note: 'write the doc first', ownerBotId: 'a', origin: { roomId: 'r1' } }, + { id: 'task-w1', title: 'Build it', title_: '', priority: 'low' }, +] +const project = { id: 'project-rocket', name: 'Rocket' } + +// The board row is the one surface that must stay a button: `role` alone opens nothing. +test('#1701: a board row shows the task title, its priority and the board that owns it', async () => { + const { projectBoardView } = await load('project-board.js') + const view = projectBoardView({ project, rows, canManage: false, tasks: taskRecords }) + const listed = view.groups.flatMap(group => group.rows) + const bot = listed.find(row => row.id === 'task-b1') + const group = listed.find(row => row.id === 'task-g1') + assert.equal(bot.title, 'Ship the board', 'the row says what the task is') + assert.equal(bot.priority, 'high', 'the row carries the priority') + assert.deepEqual(plain(bot.board), { kind: 'bot', id: 'a' }, 'the row knows the board the task lives on') + assert.equal(group.title, 'write the doc first', 'a task with no title falls back to its note') + assert.deepEqual(plain(group.board), { kind: 'group', id: 'r1' }) +}) + +test('#1701: a board row draws the title and opens the board that owns the task', async () => { + const { ProjectBoardPane } = await load('project-board.js') + const opened = [] + const nodes = all(expand(ProjectBoardPane({ project, rows, canManage: false, tasks: taskRecords, onOpen: target => opened.push(target) }))) + const row = nodes.find(node => node.props?.['data-task-id'] === 'task-b1') + assert.ok(row, 'the row is drawn') + assert.match(textOf(row), /Ship the board/, `the row repeats the assignee instead of the title: ${textOf(row)}`) + assert.equal(row.props['data-priority'], 'high') + row.props.onClick() + assert.deepEqual(plain(opened), [{ kind: 'bot', id: 'a' }], 'clicking opens the board that owns the task') +}) + +test('#1701: the Tasks dialog offers an entry that opens the Project board', async () => { + const { KanbanDialog, window: fake } = await load('kanban.js') + states.length = 0 + states.push( + { kind: 'bot', botId: 'a' }, null, '', '', + [{ id: 'task-1', status: 'working', ownerBotId: 'a', title: 'One' }], [], [], + ) + const tree = expand(KanbanDialog({ roster: roster(), actions: { closeBoard: () => {}, tasks: async () => [], openBoard: () => {} } })) + const entry = all(tree).filter(node => typeof node.props?.['data-open-project'] === 'string') + assert.equal(entry.length, 1, 'a Bot bound to a Project gets one entry on the board') + assert.equal(entry[0].props['data-open-project'], 'project-rocket') + entry[0].props.onClick() + assert.equal(fake.location.hash, '#project=project-rocket', 'the entry opens the Project board through the hash') +}) + +test('#1701: the three board columns have their own labels', async () => { + const { installLocale, t, DICTIONARY } = await load('i18n.js') + // `t()` reads a bound locale service; the fake answers from the shipped dictionary. + installLocale({ register: () => () => {}, bind: () => (key) => DICTIONARY.zh[key] ?? key }) + for (const [key, want] of [['not-started', '未开始'], ['in-progress', '进行中'], ['done', '已完成']]) { + assert.equal(t(key), want, `${key} renders as its own Chinese label, not the raw key`) + } +}) + +// The hash a row writes is consumed by the Tasks board: it lands on the board the task lives on. +test('#1701: the Tasks board reads the task hash and lands on that board', async () => { + const { KanbanDialog, window: fake } = await load('kanban.js') + fake.location.hash = '#task=group:r1' + states.length = 0 + effects.length = 0 + setters.length = 0 + expand(KanbanDialog({ roster: roster(), actions: { closeBoard: () => {}, tasks: async () => [], openBoard: () => {} } })) + for (const effect of effects) effect() + assert.deepEqual(plain(setters.slice(0, 1)), [{ kind: 'group', roomId: 'r1' }], 'the board moves to the group the task lives on') + assert.equal(fake.location.hash, '', 'the hash is consumed, so a reload does not reopen the same board') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index eba477e9..979194ac 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -267,13 +267,34 @@ A discarded `.catch(() => {})` in the client sources either starts with `void` ( - The roster poll (`index.js` `pull`) sends `watching: !document.hidden` with each `state` call (#497), so the host knows a window is on screen and the social plugin reads friends' inboxes at its live cadence: a friend's first line, its held reply's card, the Inbox count and the unread badge arrive in that window within seconds, with no reload. - The **NEW** divider (`cells.js` `TurnTailCell` / `TurnProcessCell` / the unread opening) compares turn times with the visit's `seen` (when the chat was last left) and `entered` (when this visit opened). Once the divider has been on screen (`NewMessagePills` in `header.js`, `view.divider === 'in-view'`) and a shown turn begins after the visit opened (`movedOnSince`: a friend's next line, the user's own message; hidden exchange turns do not count), `actions.settleVisit` sets `seen` to `entered` (`settledVisit`) and every divider of the visit goes, as on leaving and coming back. A divider still scrolled out of view, or a chat where nothing new happened, keeps it until the visit ends. Same rule in every chat, including a friend's typed line in the Bot's own chat (#275). Covered by `core/test/turn-tail.test.js`. -### Project board pane (#1710) +### Project board pane (#1710, reachable since #1701) `src/client/project-board.js` draws one Project over the read model the host already aggregates -(`project-board`). `projectBoardView({ project, rows, canManage })` is pure: it groups the rows by -`source` (`bot` | `group` | `workflow`, empty sources left out), carries `column`, `assignee` and the -`runId`/`nodeId` a workflow row came from, and keeps `row.id` equal to the underlying task id, so -opening a row opens that task and the view never copies a row. An empty Project renders the empty -state, not an error. `canManage` comes from `project-read` and only decides whether the management -affordances are drawn; the refusal itself is the host's. The pane is registered on the existing -`shell.overlay` surface and opens at `#project=`. +(`project-board`). `projectBoardView({ project, rows, canManage, tasks })` is pure: it groups the rows +by `source` (`bot` | `group` | `workflow`, empty sources left out), carries `column`, `assignee` and the +`runId`/`nodeId` a workflow row came from, and keeps `row.id` equal to the underlying task id, so a row +never copies a row. The `tasks` list is the ordinary task read joined by `id` (a `Map`, one pass): a row +takes its `title`, falls back to `note`, and only then to `@assignee` / `—`, plus its `priority`, so three +cards held by the same person no longer read the same. An empty Project renders the empty state, not an +error. `canManage` comes from `project-read` and only decides whether the management affordances are +drawn; the refusal itself is the host's. + +The pane is registered on the existing `shell.overlay` surface and opens at `#project=`; the overlay +re-reads the hash on `hashchange`, so an entry that writes it while the overlay is mounted still opens +the board. The entry is the **Project board** button in `KanbanDialog`'s header: it is drawn only when the +selected Bot or group carries a `projectId`, writes `#project=` and closes the Tasks dialog, leaving +the overlay to take over. It is not a second board and not a rail row. + +A row knows the board its task lives on without re-deciding membership: the mapping follows the host's own +`source` (`group` → the task's `origin.roomId`, `bot` → its `ownerBotId`, a `workflow` row is one a run +carried in and has no board of its own). A row with a board is a button: it writes `#task=:` and +`actions.openBoard()`, and `KanbanDialog` consumes that hash once on mount, moves its scope to that Bot or +group and clears the hash, so a reload does not reopen the same board. A row without a board (or without a +task record) is not a button — `role` alone opens nothing. Opening the pane is four host reads once, with +no polling and no per-Bot read: `projects`, `project-read`, `project-board`, and the `tasks` list the rows +are joined against. Tests: `core/test/project-board-view.test.js`, `core/test/project-board.test.js`, +`core/test/project-board-entry.test.js`. + +The board's three column names go through the same dictionary as the task board: `not-started`, +`in-progress` and `done` are keys in `i18n.js`, so a zh UI reads 未开始 / 进行中 / 已完成 instead of the raw +status. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index cd810b65..ad4d5a15 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1413,13 +1413,17 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Working folder': '工作文件夹', 'Working folder: {folder}': '工作文件夹:{folder}', Folder: '文件夹', -@@ -838,6 +838,9 @@ +@@ -838,6 +838,13 @@ const ZH = { 'Up one level': '上一级', 'No folders inside.': '里面没有文件夹。', 'Some folders are not shown.': '还有一些文件夹没有显示。', + 'Project settings': '项目设置', + 'Project board': '项目看板', + 'No Project here.': '这里没有项目。', ++ // The Project board names its columns with the same keys the task record uses. ++ 'not-started': '未开始', ++ 'in-progress': '进行中', ++ 'done': '已完成', } // Host errors stay English, since Bots read them too; they are translated where the diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 66571d49..53904aa1 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1756,7 +1756,7 @@ diff --git a/src/client/index.js b/src/client/index.js actions.referenceChat = (sessionId, span) => { const controller = atMenu?.controllerOf(sessionId) if (controller === undefined || span === undefined) return -@@ -752,33 +752,48 @@ +@@ -752,33 +752,49 @@ export function apply(ctx) { installCompiledStyles(SHELL_CSS, 'shell'), installCompiledStyles(SIDEBAR_CSS, 'sidebar'), installCompiledStyles(SECRET_CSS, 'secrets'), @@ -1793,7 +1793,8 @@ diff --git a/src/client/index.js b/src/client/index.js shadow('shell.overlay', { id: 'bot-signin', order: 55, inject: face }, DeepSeekSignIn), shadow('shell.overlay', { id: 'bot-toast', order: 60, inject: face }, ErrorToast), + shadow('shell.overlay', { id: 'flybot-approvals', order: 58, inject: face }, FlyBotApprovals), -+ shadow('shell.overlay', { id: 'project-board', order: 57 }, ProjectBoardOverlay), ++ // The board's rows open the Tasks board a task lives on, which only `actions` can do (#1701). ++ shadow('shell.overlay', { id: 'project-board', order: 57, inject: face }, ProjectBoardOverlay), ] const installConversation = () => [ installCompiledStyles(CONVERSATION_CSS, 'conversation'), diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index d3a1b538..85583211 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -832,7 +832,7 @@ diff --git a/client.js b/client.js "Working folder": "工作文件夹", "Working folder: {folder}": "工作文件夹:{folder}", Folder: "文件夹", -@@ -887,7 +887,10 @@ +@@ -887,7 +887,14 @@ "Use this folder": "使用这个文件夹", "Up one level": "上一级", "No folders inside.": "里面没有文件夹。", @@ -840,7 +840,11 @@ diff --git a/client.js b/client.js + "Some folders are not shown.": "还有一些文件夹没有显示。", + "Project settings": "项目设置", + "Project board": "项目看板", -+ "No Project here.": "这里没有项目。" ++ "No Project here.": "这里没有项目。", ++ // The Project board names its columns with the same keys the task record uses. ++ "not-started": "未开始", ++ "in-progress": "进行中", ++ "done": "已完成" }; var HOST_ERRORS = [ [/^Model (.+) is not available\. Available: (.*)$/, "模型 $1 不可用。可用的:$2"], @@ -6189,10 +6193,10 @@ diff --git a/client.js b/client.js ) ) ), host); -@@ -4731,49 +4731,1666 @@ +@@ -4731,49 +4731,1687 @@ return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); } - ++ +// src/client/overlay-hooks.js +var import_react11 = require("react"); +var escapeLayers = []; @@ -7678,6 +7682,16 @@ diff --git a/client.js b/client.js + const boardTasks = boardTasksFor(tasks, archive); + const close = (0, import_react15.useCallback)(() => actions.closeBoard(), []); + useEscape(close); ++ (0, import_react15.useEffect)(() => { ++ if (typeof window === "undefined") return; ++ if (!window.location.hash.startsWith("#task=")) return; ++ const [kind, id] = window.location.hash.slice("#task=".length).split(":"); ++ window.location.hash = ""; ++ if (id === void 0 || id === "") return; ++ if (kind === "bot" && roster.byId?.[id]) setScope({ kind: "bot", botId: id }); ++ if (kind === "group" && roster.roomsById?.[id]) setScope({ kind: "group", roomId: id }); ++ }, []); ++ const projectId = scope?.kind === "bot" ? roster.byId?.[scope.botId]?.projectId : scope?.kind === "group" ? roster.roomsById?.[scope.roomId]?.projectId : void 0; + const counts2 = {}; + for (const task of [...own, ...archive]) { + const id = task.ownerBotId; @@ -7720,6 +7734,17 @@ diff --git a/client.js b/client.js + { className: "bt-mem-head" }, + (0, import_react15.createElement)("h2", { id: "bt-board-heading" }, t("Board")), + (0, import_react15.createElement)("span", { className: "bt-mem-status" }, name), ++ // #1701: the board the Bot or group belongs to opens from here; nothing else writes the ++ // Project hash, so without this the Project board was reachable only by editing the URL. ++ projectId ? (0, import_react15.createElement)("button", { ++ type: "button", ++ className: "bt-soft bt-soft-sm", ++ "data-open-project": projectId, ++ onClick: () => { ++ window.location.hash = `#project=${projectId}`; ++ close(); ++ } ++ }, t("Project board")) : null, + (0, import_react15.createElement)("span", { className: "bt-mem-head-gap" }), + (0, import_react15.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close board"), autoFocus: true, onClick: close }, (0, import_react15.createElement)(CloseIcon)) + ), @@ -7804,7 +7829,7 @@ diff --git a/client.js b/client.js + .bt-board-column{min-width:0} +} +`; -+ + // src/client/sidebar.js -var import_react8 = require("react"); +var import_react16 = require("react"); @@ -17831,10 +17856,10 @@ diff --git a/client.js b/client.js } function createBotBrowsers({ bridge, createSource: createSource2, storage = globalThis.localStorage, document: doc = globalThis.document }) { const views = /* @__PURE__ */ new Map(); -@@ -13052,11 +13052,235 @@ +@@ -13052,11 +13052,285 @@ }; - } - ++} ++ +// src/client/mobile.js +var MOBILE_MAX_WIDTH = 640; +var MOBILE_CSS = ` @@ -17941,19 +17966,38 @@ diff --git a/client.js b/client.js +var PROJECT_SOURCES = ["bot", "group", "workflow"]; +var SOURCE_LABELS = { bot: "Bots", group: "Groups", workflow: "Workflows" }; +var text = (value) => typeof value === "string" ? value.trim() : ""; -+function projectBoardView({ project, rows, canManage } = {}) { ++var boardOfRow = (row, task) => { ++ if (task === void 0) return null; ++ if (row.source === "group") { ++ const id = text(task.origin?.roomId); ++ return id === "" ? null : { kind: "group", id }; ++ } ++ if (row.source === "bot") { ++ const id = text(task.ownerBotId); ++ return id === "" ? null : { kind: "bot", id }; ++ } ++ return null; ++}; ++function projectBoardView({ project, rows, canManage, tasks } = {}) { + const list = Array.isArray(rows) ? rows.filter((row) => row !== null && typeof row === "object" && text(row.id) !== "") : []; ++ const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task])); + const groups = PROJECT_SOURCES.map((source) => ({ + source, + label: SOURCE_LABELS[source], -+ rows: list.filter((row) => row.source === source).map((row) => ({ -+ id: text(row.id), -+ key: `task:${text(row.id)}`, -+ column: text(row.column) || "not-started", -+ assignee: text(row.assignee) || null, -+ runId: text(row.runId) || null, -+ nodeId: text(row.nodeId) || null -+ })) ++ rows: list.filter((row) => row.source === source).map((row) => { ++ const task = byId.get(text(row.id)); ++ return { ++ id: text(row.id), ++ key: `task:${text(row.id)}`, ++ column: text(row.column) || "not-started", ++ 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, ++ board: boardOfRow(row, task) ++ }; ++ }) + })).filter((group) => group.rows.length > 0); + return { + projectId: text(project?.id) || null, @@ -17971,28 +18015,33 @@ diff --git a/client.js b/client.js + "section", + { key: group.source, className: "bt-project-group", "data-source": group.source }, + (0, import_react46.createElement)("h4", null, t(group.label)), -+ group.rows.map((row) => (0, import_react46.createElement)( -+ "div", -+ { -+ key: row.key, -+ className: "bt-project-row", -+ "data-task-id": row.id, -+ "data-column": row.column, -+ role: "button", -+ tabIndex: 0, -+ onClick: () => onOpen?.(row.id), -+ onKeyDown: (event) => { -+ if (event.key === "Enter" || event.key === " ") onOpen?.(row.id); -+ } -+ }, -+ (0, import_react46.createElement)("span", { className: "bt-project-column" }, t(row.column)), -+ (0, import_react46.createElement)("span", { className: "bt-project-title" }, row.assignee ? `@${row.assignee}` : "—"), -+ row.runId === null ? null : (0, import_react46.createElement)("span", { className: "bt-project-ref" }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`) -+ )) ++ group.rows.map((row) => { ++ const open = row.board === null ? void 0 : () => onOpen?.(row.board); ++ return (0, import_react46.createElement)( ++ "div", ++ { ++ key: row.key, ++ className: "bt-project-row", ++ "data-task-id": row.id, ++ "data-column": row.column, ++ "data-priority": row.priority ?? void 0, ++ role: open === void 0 ? void 0 : "button", ++ tabIndex: open === void 0 ? void 0 : 0, ++ onClick: open, ++ onKeyDown: open === void 0 ? void 0 : (event) => { ++ if (event.key === "Enter" || event.key === " ") open(); ++ } ++ }, ++ (0, import_react46.createElement)("span", { className: "bt-project-column" }, t(row.column)), ++ (0, import_react46.createElement)("span", { className: "bt-project-title" }, row.title ?? (row.assignee ? `@${row.assignee}` : "—")), ++ row.priority === null ? null : (0, import_react46.createElement)("span", { className: "bt-project-priority" }, row.priority), ++ row.runId === null ? null : (0, import_react46.createElement)("span", { className: "bt-project-ref" }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`) ++ ); ++ }) + )); +} -+function ProjectBoardPane({ project, rows, canManage, onOpen }) { -+ const view = projectBoardView({ project, rows, canManage }); ++function ProjectBoardPane({ project, rows, canManage, tasks, onOpen }) { ++ const view = projectBoardView({ project, rows, canManage, tasks }); + return (0, import_react46.createElement)( + "div", + { className: "bt-project-board", "data-can-manage": view.canManage ? "true" : "false" }, @@ -18002,16 +18051,27 @@ diff --git a/client.js b/client.js + (0, import_react46.createElement)(ProjectBoardRows, { view, onOpen }) + ); +} -+function ProjectBoardOverlay() { ++function ProjectBoardOverlay({ actions } = {}) { + const [state, setState] = (0, import_react46.useState)(null); ++ const [hash, setHash] = (0, import_react46.useState)(() => typeof window === "undefined" ? "" : window.location.hash); + const close = (0, import_react46.useCallback)(() => { + if (typeof window !== "undefined") window.location.hash = ""; + setState(null); + }, []); + (0, import_react46.useEffect)(() => { -+ if (typeof window === "undefined") return; -+ if (!window.location.hash.startsWith("#project")) return; -+ const wanted = window.location.hash.slice("#project".length).replace(/^=/, ""); ++ if (typeof window === "undefined") return void 0; ++ const sync = () => setHash(window.location.hash); ++ window.addEventListener("hashchange", sync); ++ sync(); ++ return () => window.removeEventListener("hashchange", sync); ++ }, []); ++ (0, import_react46.useEffect)(() => { ++ if (typeof window === "undefined") return void 0; ++ if (!hash.startsWith("#project")) { ++ setState(null); ++ return void 0; ++ } ++ const wanted = hash.slice("#project".length).replace(/^=/, ""); + let live = true; + void (async () => { + try { @@ -18019,27 +18079,42 @@ diff --git a/client.js b/client.js + const projects = listing?.projects ?? []; + const project = wanted === "" ? projects[0] : projects.find((item) => item.id === wanted); + if (project === void 0) { -+ if (live) setState({ project: null, rows: [], canManage: false }); ++ if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }); + return; + } -+ const [read, board] = await Promise.all([call("project-read", { id: project.id }), call("project-board", { id: project.id })]); -+ if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true }); ++ const [read, board, taskList] = await Promise.all([ ++ call("project-read", { id: project.id }), ++ call("project-board", { id: project.id }), ++ call("tasks", {}) ++ ]); ++ const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? []; ++ if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true, tasks }); + } catch { -+ if (live) setState({ project: null, rows: [], canManage: false }); ++ if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }); + } + })(); + return () => { + live = false; + }; -+ }, []); ++ }, [hash]); + if (state === null) return null; + return (0, import_react46.createElement)( + "div", + { className: "bt-overlay bt-project-overlay", role: "dialog", "aria-label": t("Project board") }, + (0, import_react46.createElement)("button", { type: "button", className: "bt-project-close", onClick: close }, t("Close")), -+ state.project === null ? (0, import_react46.createElement)("div", { className: "bt-project-empty" }, t("No Project here.")) : (0, import_react46.createElement)(ProjectBoardPane, { project: state.project, rows: state.rows, canManage: state.canManage, onOpen: (id) => { -+ window.location.hash = `#task=${id}`; -+ } }) ++ state.project === null ? (0, import_react46.createElement)("div", { className: "bt-project-empty" }, t("No Project here.")) : (0, import_react46.createElement)(ProjectBoardPane, { ++ project: state.project, ++ rows: state.rows, ++ canManage: state.canManage, ++ tasks: state.tasks, ++ // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and ++ // lands on that Bot or group. Nothing here re-decides which board that is. ++ onOpen: (board) => { ++ if (typeof window !== "undefined") window.location.hash = `#task=${board.kind}:${board.id}`; ++ actions?.openBoard?.(); ++ setState(null); ++ } ++ }) + ); +} + @@ -18057,8 +18132,8 @@ diff --git a/client.js b/client.js + if (known && (shown === void 0 || at > shown) && !isOpen(bot)) unread.push(bot.id); + } + return { known: next, unread }; -+} -+ + } + // src/client/surface.js function groupsFor({ mode, plain = false }) { return { shell: mode === "bot", conversation: mode === "bot" && !plain }; @@ -18787,7 +18862,7 @@ diff --git a/client.js b/client.js actions.referenceChat = (sessionId, span) => { const controller = atMenu?.controllerOf(sessionId); if (controller === void 0 || span === void 0) return; -@@ -14019,33 +14019,48 @@ +@@ -14019,33 +14019,49 @@ installCompiledStyles(SHELL_CSS, "shell"), installCompiledStyles(SIDEBAR_CSS, "sidebar"), installCompiledStyles(SECRET_CSS, "secrets"), @@ -18825,7 +18900,8 @@ diff --git a/client.js b/client.js - shadow("shell.overlay", { id: "bot-toast", order: 60, inject: face }, ErrorToast) + shadow("shell.overlay", { id: "bot-toast", order: 60, inject: face }, ErrorToast), + shadow("shell.overlay", { id: "flybot-approvals", order: 58, inject: face }, FlyBotApprovals), -+ shadow("shell.overlay", { id: "project-board", order: 57 }, ProjectBoardOverlay) ++ // The board's rows open the Tasks board a task lives on, which only `actions` can do (#1701). ++ shadow("shell.overlay", { id: "project-board", order: 57, inject: face }, ProjectBoardOverlay) ]; const installConversation = () => [ installCompiledStyles(CONVERSATION_CSS, "conversation"), diff --git a/patches/ds-bot/files/src/client/kanban.js b/patches/ds-bot/files/src/client/kanban.js index d3dd00e3..d8d0d134 100644 --- a/patches/ds-bot/files/src/client/kanban.js +++ b/patches/ds-bot/files/src/client/kanban.js @@ -368,6 +368,21 @@ export function KanbanDialog({ roster, actions, leaving = false }) { const boardTasks = boardTasksFor(tasks, archive) const close = useCallback(() => actions.closeBoard(), []) useEscape(close) + // #1701: a Project board row opens the board its task lives on by naming it here, so the id a + // row carries is read rather than written into a hash nothing consumed. + useEffect(() => { + if (typeof window === 'undefined') return + if (!window.location.hash.startsWith('#task=')) return + const [kind, id] = window.location.hash.slice('#task='.length).split(':') + window.location.hash = '' + if (id === undefined || id === '') return + if (kind === 'bot' && roster.byId?.[id]) setScope({ kind: 'bot', botId: id }) + if (kind === 'group' && roster.roomsById?.[id]) setScope({ kind: 'group', roomId: id }) + }, []) + // The Project this board belongs to, when its Bot or group is bound to one. + const projectId = scope?.kind === 'bot' + ? roster.byId?.[scope.botId]?.projectId + : scope?.kind === 'group' ? roster.roomsById?.[scope.roomId]?.projectId : undefined const counts = {} for (const task of [...own, ...archive]) { const id = task.ownerBotId @@ -404,6 +419,12 @@ export function KanbanDialog({ roster, actions, leaving = false }) { h('div', { className: 'bt-mem-head' }, h('h2', { id: 'bt-board-heading' }, t('Board')), h('span', { className: 'bt-mem-status' }, name), + // #1701: the board the Bot or group belongs to opens from here; nothing else writes the + // Project hash, so without this the Project board was reachable only by editing the URL. + projectId ? h('button', { + type: 'button', className: 'bt-soft bt-soft-sm', 'data-open-project': projectId, + onClick: () => { window.location.hash = `#project=${projectId}`; close() }, + }, t('Project board')) : null, h('span', { className: 'bt-mem-head-gap' }), h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close board'), autoFocus: true, onClick: close }, h(CloseIcon))), h(BoardView, { diff --git a/patches/ds-bot/files/src/client/project-board.js b/patches/ds-bot/files/src/client/project-board.js index 192f6fc1..f2e1aa94 100644 --- a/patches/ds-bot/files/src/client/project-board.js +++ b/patches/ds-bot/files/src/client/project-board.js @@ -23,24 +23,50 @@ export const SOURCE_LABELS = { bot: 'Bots', group: 'Groups', workflow: 'Workflow const text = value => (typeof value === 'string' ? value.trim() : '') +// 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. +const boardOfRow = (row, task) => { + if (task === undefined) return null + if (row.source === 'group') { + const id = text(task.origin?.roomId) + return id === '' ? null : { kind: 'group', id } + } + if (row.source === 'bot') { + const id = text(task.ownerBotId) + return id === '' ? null : { kind: 'bot', id } + } + return null +} + // 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. -export function projectBoardView({ project, rows, canManage } = {}) { +// `tasks` is the one task read joined by id: the row carries an id and who holds it, not its text. +export function projectBoardView({ project, rows, canManage, tasks } = {}) { const list = Array.isArray(rows) ? rows.filter(row => row !== null && typeof row === 'object' && text(row.id) !== '') : [] + const byId = new Map((Array.isArray(tasks) ? tasks : []) + .filter(task => task !== null && typeof task === 'object' && text(task.id) !== '') + .map(task => [text(task.id), task])) const groups = PROJECT_SOURCES .map(source => ({ source, label: SOURCE_LABELS[source], rows: list .filter(row => row.source === source) - .map(row => ({ - id: text(row.id), - key: `task:${text(row.id)}`, - column: text(row.column) || 'not-started', - assignee: text(row.assignee) || null, - runId: text(row.runId) || null, - nodeId: text(row.nodeId) || null, - })), + .map((row) => { + const task = byId.get(text(row.id)) + return { + id: text(row.id), + key: `task:${text(row.id)}`, + column: text(row.column) || 'not-started', + 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, + board: boardOfRow(row, task), + } + }), })) .filter(group => group.rows.length > 0) return { @@ -59,23 +85,28 @@ function ProjectBoardRows({ view, onOpen }) { if (view.empty) return h('div', { className: 'bt-project-empty' }, t(view.emptyText)) return view.groups.map(group => h('section', { key: group.source, className: 'bt-project-group', 'data-source': group.source }, h('h4', null, t(group.label)), - group.rows.map(row => h('div', { - key: row.key, - className: 'bt-project-row', - 'data-task-id': row.id, - 'data-column': row.column, - role: 'button', - tabIndex: 0, - onClick: () => onOpen?.(row.id), - onKeyDown: event => { if (event.key === 'Enter' || event.key === ' ') onOpen?.(row.id) }, - }, - h('span', { className: 'bt-project-column' }, t(row.column)), - h('span', { className: 'bt-project-title' }, row.assignee ? `@${row.assignee}` : '—'), - row.runId === null ? null : h('span', { className: 'bt-project-ref' }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`))))) + group.rows.map((row) => { + const open = row.board === null ? undefined : () => onOpen?.(row.board) + return h('div', { + key: row.key, + className: 'bt-project-row', + 'data-task-id': row.id, + 'data-column': row.column, + 'data-priority': row.priority ?? undefined, + role: open === undefined ? undefined : 'button', + tabIndex: open === undefined ? undefined : 0, + onClick: open, + onKeyDown: open === undefined ? undefined : event => { if (event.key === 'Enter' || event.key === ' ') open() }, + }, + h('span', { className: 'bt-project-column' }, t(row.column)), + h('span', { className: 'bt-project-title' }, row.title ?? (row.assignee ? `@${row.assignee}` : '—')), + row.priority === null ? null : h('span', { className: 'bt-project-priority' }, row.priority), + row.runId === null ? null : h('span', { className: 'bt-project-ref' }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`)) + }))) } -export function ProjectBoardPane({ project, rows, canManage, onOpen }) { - const view = projectBoardView({ project, rows, canManage }) +export function ProjectBoardPane({ project, rows, canManage, tasks, onOpen }) { + const view = projectBoardView({ project, rows, canManage, tasks }) return h('div', { className: 'bt-project-board', 'data-can-manage': view.canManage ? 'true' : 'false' }, h('h3', null, view.title), // Management affordances are drawn only for the owner; the refusal itself is the host's. @@ -85,33 +116,61 @@ export function ProjectBoardPane({ project, rows, canManage, onOpen }) { // The pane entry point (#1710): `#project=` opens one Project's board, `#project` the first. // Reading is a POST through the same host channel every other view uses. -export function ProjectBoardOverlay() { +export function ProjectBoardOverlay({ actions } = {}) { const [state, setState] = useState(null) + // The hash is the whole entry point, so it has to be read again every time it changes: an + // entry that writes `#project=` while this mounts opened nothing when the read was one-shot. + const [hash, setHash] = useState(() => (typeof window === 'undefined' ? '' : window.location.hash)) const close = useCallback(() => { if (typeof window !== 'undefined') window.location.hash = '' setState(null) }, []) useEffect(() => { - if (typeof window === 'undefined') return - if (!window.location.hash.startsWith('#project')) return - const wanted = window.location.hash.slice('#project'.length).replace(/^=/, '') + if (typeof window === 'undefined') return undefined + const sync = () => setHash(window.location.hash) + window.addEventListener('hashchange', sync) + sync() + return () => window.removeEventListener('hashchange', sync) + }, []) + useEffect(() => { + if (typeof window === 'undefined') return undefined + if (!hash.startsWith('#project')) { setState(null); return undefined } + const wanted = hash.slice('#project'.length).replace(/^=/, '') let live = true void (async () => { try { const listing = await call('projects') const projects = listing?.projects ?? [] const project = wanted === '' ? projects[0] : projects.find(item => item.id === wanted) - if (project === undefined) { if (live) setState({ project: null, rows: [], canManage: false }); return } - const [read, board] = await Promise.all([call('project-read', { id: project.id }), call('project-board', { id: project.id })]) - if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true }) - } catch { if (live) setState({ project: null, rows: [], canManage: false }) } + if (project === undefined) { if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }); return } + // Four reads once, none polling: the Project, its record, its rows, and the task texts the + // rows are joined against. Nothing here asks a Bot by Bot. + const [read, board, taskList] = await Promise.all([ + call('project-read', { id: project.id }), + call('project-board', { id: project.id }), + call('tasks', {}), + ]) + const tasks = Array.isArray(taskList) ? taskList : (taskList?.tasks ?? []) + if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true, tasks }) + } catch { if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }) } })() return () => { live = false } - }, []) + }, [hash]) if (state === null) return null return h('div', { className: 'bt-overlay bt-project-overlay', role: 'dialog', 'aria-label': t('Project board') }, h('button', { type: 'button', className: 'bt-project-close', onClick: close }, t('Close')), state.project === null ? h('div', { className: 'bt-project-empty' }, t('No Project here.')) - : h(ProjectBoardPane, { project: state.project, rows: state.rows, canManage: state.canManage, onOpen: id => { window.location.hash = `#task=${id}` } })) + // A row opens the board its task lives on, carried by `#task=`: the Tasks board reads + // that hash and lands on the Bot or group the task belongs to. + : h(ProjectBoardPane, { + project: state.project, rows: state.rows, canManage: state.canManage, tasks: state.tasks, + // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and + // lands on that Bot or group. Nothing here re-decides which board that is. + onOpen: (board) => { + if (typeof window !== 'undefined') window.location.hash = `#task=${board.kind}:${board.id}` + actions?.openBoard?.() + setState(null) + }, + })) } diff --git a/patches/ds-bot/files/test/project-board-entry.test.js b/patches/ds-bot/files/test/project-board-entry.test.js new file mode 100644 index 00000000..3b05a96c --- /dev/null +++ b/patches/ds-bot/files/test/project-board-entry.test.js @@ -0,0 +1,157 @@ +// Project board, the reachable slice (#1701): #1715 shipped the board itself, but nothing writes +// `#project=`, a row only repeats `@assignee`, and clicking one sets a hash no view reads. +// These tests pin the three surfaces a reader needs — an entry that opens the board, a row that +// says which task it is, and a row that opens the board the task lives on — plus the three column +// labels the board already asks `t()` for. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const states = [] +const effects = [] +const setters = [] + +const load = async (entry) => { + const bundle = await build({ + entryPoints: [fileURLToPath(new URL(`../src/client/${entry}`, import.meta.url))], + bundle: true, format: 'cjs', platform: 'node', write: false, + external: ['react', 'react-dom'], + }) + const module = { exports: {} } + const sandbox = { + module, exports: module.exports, setTimeout, clearTimeout, + setInterval: () => 0, clearInterval: () => {}, Date, Promise, console, + window: { location: { hash: '' }, addEventListener: () => {}, removeEventListener: () => {} }, + require: (name) => { + const platform = { + react: { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useState: (initial) => { + const value = states.length > 0 ? states.shift() : (typeof initial === 'function' ? initial() : initial) + return [value, (next) => { setters.push(typeof next === 'function' ? next(value) : next) }] + }, + useEffect: fn => { effects.push(fn) }, + useLayoutEffect: fn => { effects.push(fn) }, + useCallback: fn => fn, + useMemo: fn => fn(), + useRef: current => ({ current }), + }, + } + if (!(name in platform)) throw new Error(`no platform module ${name}`) + return platform[name] + }, + } + runInNewContext(bundle.outputFiles[0].text, sandbox) + return { ...module.exports, window: sandbox.window } +} + +const expand = (node) => { + if (Array.isArray(node)) return node.flatMap(expand) + if (node === null || typeof node !== 'object') return node + if (typeof node.type === 'function') 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('') : '') +// Arrays and objects minted inside the vm have another realm's prototype, so compare copies. +const plain = value => (value === undefined ? value : JSON.parse(JSON.stringify(value))) + +const roster = () => { + const view = { + self: { memberId: 'm1' }, + bots: [ + { id: 'a', name: 'Chief', ownerId: 'm1', createdAt: 1, projectId: 'project-rocket' }, + { id: 'b', name: 'Loose', ownerId: 'm1', createdAt: 2 }, + ], + rooms: [ + { id: 'r1', name: 'Ops', admin: 'a', projectId: 'project-rocket', participants: [{ kind: 'human', id: 'm1', role: 'member' }] }, + { id: 'r2', name: 'Garden', admin: 'a', participants: [{ kind: 'human', id: 'm1', role: 'member' }] }, + ], + } + view.byId = Object.fromEntries(view.bots.map(bot => [bot.id, bot])) + view.roomsById = Object.fromEntries(view.rooms.map(room => [room.id, room])) + return view +} + +// The rows #1715's host read model returns: an id, a source, a column and who holds it — no text. +const rows = [ + { id: 'task-b1', source: 'bot', column: 'in-progress', assignee: 'Dev4', updatedAt: 3 }, + { id: 'task-g1', source: 'group', column: 'done', assignee: 'Chief', updatedAt: 2 }, + { id: 'task-w1', source: 'workflow', column: 'not-started', assignee: null, runId: 'run-7', nodeId: 'build', updatedAt: 1 }, +] +// The task records the board joins by id, so the card can say what the row is about. +const taskRecords = [ + { id: 'task-b1', title: 'Ship the board', priority: 'high', ownerBotId: 'a' }, + { id: 'task-g1', note: 'write the doc first', ownerBotId: 'a', origin: { roomId: 'r1' } }, + { id: 'task-w1', title: 'Build it', title_: '', priority: 'low' }, +] +const project = { id: 'project-rocket', name: 'Rocket' } + +// The board row is the one surface that must stay a button: `role` alone opens nothing. +test('#1701: a board row shows the task title, its priority and the board that owns it', async () => { + const { projectBoardView } = await load('project-board.js') + const view = projectBoardView({ project, rows, canManage: false, tasks: taskRecords }) + const listed = view.groups.flatMap(group => group.rows) + const bot = listed.find(row => row.id === 'task-b1') + const group = listed.find(row => row.id === 'task-g1') + assert.equal(bot.title, 'Ship the board', 'the row says what the task is') + assert.equal(bot.priority, 'high', 'the row carries the priority') + assert.deepEqual(plain(bot.board), { kind: 'bot', id: 'a' }, 'the row knows the board the task lives on') + assert.equal(group.title, 'write the doc first', 'a task with no title falls back to its note') + assert.deepEqual(plain(group.board), { kind: 'group', id: 'r1' }) +}) + +test('#1701: a board row draws the title and opens the board that owns the task', async () => { + const { ProjectBoardPane } = await load('project-board.js') + const opened = [] + const nodes = all(expand(ProjectBoardPane({ project, rows, canManage: false, tasks: taskRecords, onOpen: target => opened.push(target) }))) + const row = nodes.find(node => node.props?.['data-task-id'] === 'task-b1') + assert.ok(row, 'the row is drawn') + assert.match(textOf(row), /Ship the board/, `the row repeats the assignee instead of the title: ${textOf(row)}`) + assert.equal(row.props['data-priority'], 'high') + row.props.onClick() + assert.deepEqual(plain(opened), [{ kind: 'bot', id: 'a' }], 'clicking opens the board that owns the task') +}) + +test('#1701: the Tasks dialog offers an entry that opens the Project board', async () => { + const { KanbanDialog, window: fake } = await load('kanban.js') + states.length = 0 + states.push( + { kind: 'bot', botId: 'a' }, null, '', '', + [{ id: 'task-1', status: 'working', ownerBotId: 'a', title: 'One' }], [], [], + ) + const tree = expand(KanbanDialog({ roster: roster(), actions: { closeBoard: () => {}, tasks: async () => [], openBoard: () => {} } })) + const entry = all(tree).filter(node => typeof node.props?.['data-open-project'] === 'string') + assert.equal(entry.length, 1, 'a Bot bound to a Project gets one entry on the board') + assert.equal(entry[0].props['data-open-project'], 'project-rocket') + entry[0].props.onClick() + assert.equal(fake.location.hash, '#project=project-rocket', 'the entry opens the Project board through the hash') +}) + +test('#1701: the three board columns have their own labels', async () => { + const { installLocale, t, DICTIONARY } = await load('i18n.js') + // `t()` reads a bound locale service; the fake answers from the shipped dictionary. + installLocale({ register: () => () => {}, bind: () => (key) => DICTIONARY.zh[key] ?? key }) + for (const [key, want] of [['not-started', '未开始'], ['in-progress', '进行中'], ['done', '已完成']]) { + assert.equal(t(key), want, `${key} renders as its own Chinese label, not the raw key`) + } +}) + +// The hash a row writes is consumed by the Tasks board: it lands on the board the task lives on. +test('#1701: the Tasks board reads the task hash and lands on that board', async () => { + const { KanbanDialog, window: fake } = await load('kanban.js') + fake.location.hash = '#task=group:r1' + states.length = 0 + effects.length = 0 + setters.length = 0 + expand(KanbanDialog({ roster: roster(), actions: { closeBoard: () => {}, tasks: async () => [], openBoard: () => {} } })) + for (const effect of effects) effect() + assert.deepEqual(plain(setters.slice(0, 1)), [{ kind: 'group', roomId: 'r1' }], 'the board moves to the group the task lives on') + assert.equal(fake.location.hash, '', 'the hash is consumed, so a reload does not reopen the same board') +})