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/1710.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A Project now has a board of its own: one read-only view over the work under it, grouped by where it lives (Bots, groups, workflow runs), showing each row's column, assignee and the run and node it came from, with every row opening the task it comes from. A Project with nothing under it says so instead of failing. Only the Project's owner sees the management controls, and the host refuses a binding or a Project change from anyone else rather than relying on the page to hide a button. Open the board at `#project=<id>` ([#1710](https://github.com/ThinkFlowLab/FlyBot/issues/1710)).
776 changes: 449 additions & 327 deletions core/client.js

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,9 @@ const ZH = {
'Up one level': '上一级',
'No folders inside.': '里面没有文件夹。',
'Some folders are not shown.': '还有一些文件夹没有显示。',
'Project settings': '项目设置',
'Project board': '项目看板',
'No Project here.': '这里没有项目。',
}

// Host errors stay English, since Bots read them too; they are translated where the
Expand Down
3 changes: 3 additions & 0 deletions core/src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { readWorkflows } from './workflows.js'
import { KANBAN_CSS } from './kanban.js'
import { MOBILE_CSS } from './mobile.js'
import { PANE_MIN_WIDTH_CSS, watchShellLevel } from './panes.js'
import { PROJECT_BOARD_CSS, ProjectBoardOverlay } from './project-board.js'
import { STORAGE, migrateStorage } from './storage.js'
import { friendLineNews } from './friend-lines.js'
import { groupsFor, readMode, surfaceGroups, writeMode } from './surface.js'
Expand Down Expand Up @@ -891,6 +892,7 @@ export function apply(ctx) {
installCompiledStyles(USER_MESSAGE_CSS, 'user-messages'),
installCompiledStyles(MOBILE_CSS, 'mobile'),
installCompiledStyles(PANE_MIN_WIDTH_CSS, 'panes'),
installCompiledStyles(PROJECT_BOARD_CSS, 'project-board'),
typeof document !== 'undefined' ? watchShellLevel(document) : null,
browsers ? installCompiledStyles(BROWSER_CSS, 'browser') : null,
installTheme(),
Expand All @@ -909,6 +911,7 @@ 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),
]
const installConversation = () => [
installCompiledStyles(CONVERSATION_CSS, 'conversation'),
Expand Down
117 changes: 117 additions & 0 deletions core/src/client/project-board.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// The Project board view (#1710): one Project, the rows its read model already aggregated
// (host `project-board`), grouped by where the work lives. The rows are the tasks themselves —
// `row.id` is the underlying task id — so opening one opens that task and nothing is copied here.
import { createElement as h, useCallback, useEffect, useState } from 'react'
import { call } from './api.js'
import { t } from './i18n.js'

export const PROJECT_BOARD_CSS = `
.bt-project-board{padding:16px 18px}
.bt-project-group{margin:0 0 14px}
.bt-project-group h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
.bt-project-row{display:flex;gap:10px;align-items:center;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px;cursor:default}
.bt-project-row[data-column="done"]{opacity:.7}
.bt-project-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6}
.bt-project-empty{padding:22px 4px;opacity:.7}
.bt-project-actions{display:flex;gap:8px;margin:0 0 14px}
`

// Sources in the order the board reads them; a source with no rows is left out so three empty
// groups never crowd out the one that has work.
export const PROJECT_SOURCES = ['bot', 'group', 'workflow']
export const SOURCE_LABELS = { bot: 'Bots', group: 'Groups', workflow: 'Workflows' }

const text = value => (typeof value === 'string' ? value.trim() : '')

// 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 } = {}) {
const list = Array.isArray(rows) ? rows.filter(row => row !== null && typeof row === 'object' && text(row.id) !== '') : []
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,
})),
}))
.filter(group => group.rows.length > 0)
return {
projectId: text(project?.id) || null,
title: text(project?.name) || 'Project',
canManage: canManage === true,
groups,
count: list.length,
empty: list.length === 0,
emptyText: 'Nothing under this Project yet.',
}
}

function ProjectBoardRows({ view, onOpen }) {
// An empty Project is a state, not an error: the view says so and renders no groups.
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}`)))))
}

export function ProjectBoardPane({ project, rows, canManage, onOpen }) {
const view = projectBoardView({ project, rows, canManage })
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.
view.canManage ? h('div', { className: 'bt-project-actions' }, h('button', { type: 'button' }, t('Project settings')), h('button', { type: 'button' }, t('Members'))) : null,
h(ProjectBoardRows, { view, onOpen }))
}

// The pane entry point (#1710): `#project=<id>` opens one Project's board, `#project` the first.
// Reading is a POST through the same host channel every other view uses.
export function ProjectBoardOverlay() {
const [state, setState] = useState(null)
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(/^=/, '')
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 }) }
})()
return () => { live = false }
}, [])
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}` } }))
}
2 changes: 1 addition & 1 deletion core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -673,7 +673,7 @@ export function install(rt) {
case 'project-of':
case 'project-board':
await rt.load()
return ok(await rt.projectEndpoint(endpoint, body))
return ok(await rt.projectEndpoint(endpoint, body, actor))
case 'share-brief':
await rt.load()
await rt.setBriefShare(String(body.id ?? ''), String(body.botId ?? ''), body.share === true, actor)
Expand Down
32 changes: 29 additions & 3 deletions core/src/host/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -433,16 +433,42 @@ export function install(rt) {
if (project !== null) project.updatedAt = Math.max(project.updatedAt, Date.now())
return project
}
const projectEndpoint = async (endpoint, body = {}) => {
// Who may act on a Project (#1710). The record names its owner member; an install with no
// session falls back to the first member, exactly like `requesterOf` in tasks.js. The host
// decides this, never the page: a caller that is not the owner may read and nothing else.
const projectActorId = actor => projectText(actor?.memberId, 120) || Object.keys(state.members ?? {})[0] || null
const projectCanManage = (project, actor) => {
if (project === null || typeof project !== 'object') return false
const owner = projectText(project.ownerUserId, 120)
if (owner === '') return true
const actorId = projectActorId(actor)
return actorId !== null && actorId === owner
}
const requireProjectOwner = (project, actor) => {
if (project === null) return
if (projectCanManage(project, actor)) return
throw projectRefusal('Only the owner of this Project may change it', 'bot/forbidden')
}

const projectEndpoint = async (endpoint, body = {}, actor = null) => {
switch (endpoint) {
case 'projects': return { projects: listProjects() }
case 'project-create': {
const project = createProject(body)
// The caller is stamped as the owner; a member id from the body cannot mint a
// Project someone else manages.
const project = createProject({ ...body, ownerUserId: projectActorId(actor) ?? undefined })
await save()
return { project }
}
case 'project-read': return { project: readProject(projectText(body.id, 200)) }
case 'project-read': {
const project = readProject(projectText(body.id, 200))
return { project, canManage: projectCanManage(project, actor) }
}
case 'project-bind': {
const target = body.projectId === undefined || body.projectId === null
? projectOfMember(projectText(body.kind, 20), projectText(body.id, 200))
: requireProject(projectText(body.projectId, 200))
requireProjectOwner(target, actor)
const project = bindProject(body)
await save()
return { project }
Expand Down
107 changes: 107 additions & 0 deletions core/test/project-board-view.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// The Project board view (#1710): one view over the read model Stage 2.2 shipped. It groups the
// rows by where the work lives, carries the run/node a workflow row came from, and keeps the row
// id equal to the underlying task id, so opening a row opens that task and no row is a copy.
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 bundle = await build({
entryPoints: [fileURLToPath(new URL('../src/client/project-board.js', import.meta.url))],
bundle: true, format: 'cjs', platform: 'node', write: false,
external: ['react', 'react-dom'],
})
const module = { exports: {} }
runInNewContext(bundle.outputFiles[0].text, {
module, exports: module.exports, setTimeout, clearTimeout, setInterval, clearInterval, Date, Promise, console,
require: (name) => {
const platform = {
react: {
createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }),
useCallback: fn => fn,
useEffect: () => {},
useState: initial => [initial, () => {}],
},
}
if (!(name in platform)) throw new Error(`no platform module ${name}`)
return platform[name]
},
})
const { ProjectBoardPane, projectBoardView } = module.exports

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 },
]

const all = (node, out = []) => {
if (Array.isArray(node)) { for (const item of node) all(item, out); return out }
if (node === null || typeof node !== 'object') return out
// The fake createElement does not render components; the test asks them for their tree.
if (typeof node.type === 'function') return all(node.type(node.props), out)
out.push(node)
all(node.children, out)
return out
}
// Arrays minted inside the vm have another realm's prototype, so compare plain copies.
const plain = value => JSON.parse(JSON.stringify(value))
const textOf = node => (typeof node === 'string' ? node : node && typeof node === 'object' ? (node.children ?? []).map(textOf).join('') : '')

test('the board groups rows from all three sources and keeps each task id', () => {
const view = plain(projectBoardView({ project: { id: 'project-1', name: 'FlyBot' }, rows, canManage: true }))
assert.equal(view.projectId, 'project-1')
assert.equal(view.title, 'FlyBot')
assert.equal(view.canManage, true)
assert.equal(view.empty, false)
assert.equal(view.count, 3)
assert.deepEqual(view.groups.map(group => group.source), ['bot', 'group', 'workflow'])
assert.deepEqual(view.groups.map(group => group.rows.length), [1, 1, 1])
const ids = view.groups.flatMap(group => group.rows.map(row => row.id))
assert.deepEqual(ids, ['task-b1', 'task-g1', 'task-w1'], 'the row id is the task id')
assert.deepEqual(view.groups.flatMap(group => group.rows.map(row => row.key)), ['task:task-b1', 'task:task-g1', 'task:task-w1'])
})

test('a workflow row carries its run and node; other rows carry none', () => {
const view = plain(projectBoardView({ project: { id: 'p', name: 'P' }, rows, canManage: false }))
const [botRow] = view.groups[0].rows
const workflowRow = view.groups[2].rows[0]
assert.equal(botRow.runId, null)
assert.equal(botRow.nodeId, null)
assert.equal(workflowRow.runId, 'run-7')
assert.equal(workflowRow.nodeId, 'build')
assert.equal(workflowRow.assignee, null)
assert.equal(botRow.column, 'in-progress')
})

test('a Project with nothing under it is an empty state, not an error', () => {
const view = plain(projectBoardView({ project: { id: 'p', name: 'P' }, rows: [], canManage: false }))
assert.equal(view.empty, true)
assert.deepEqual(view.groups, [])
assert.equal(view.count, 0)
const missing = plain(projectBoardView({}))
assert.equal(missing.empty, true)
assert.equal(missing.projectId, null)
assert.equal(missing.canManage, false, 'no canManage answer is read as read-only')
})

test('the pane draws the grouped rows and only the owner sees the management controls', () => {
const owner = all(ProjectBoardPane({ project: { id: 'p', name: 'FlyBot' }, rows, canManage: true }))
const member = all(ProjectBoardPane({ project: { id: 'p', name: 'FlyBot' }, rows, canManage: false }))
assert.match(textOf(owner[0]), /FlyBot/)
const ownerRows = owner.filter(node => node.props?.['data-task-id'] !== undefined)
assert.deepEqual(ownerRows.map(node => node.props['data-task-id']), ['task-b1', 'task-g1', 'task-w1'])
const workflow = ownerRows[2]
assert.match(textOf(workflow), /run-7 · build/)
assert.equal(owner.some(node => textOf(node) === 'Project settings'), true)
const memberRows = member.filter(node => node.props?.['data-task-id'] !== undefined)
assert.deepEqual(memberRows.map(node => node.props['data-task-id']), ['task-b1', 'task-g1', 'task-w1'], 'a member still reads the board')
assert.equal(member.some(node => textOf(node) === 'Project settings'), false)
})

test('an empty Project renders the empty text and no row', () => {
const nodes = all(ProjectBoardPane({ project: { id: 'p', name: 'P' }, rows: [], canManage: false }))
assert.equal(nodes.filter(node => node.props?.['data-task-id'] !== undefined).length, 0)
assert.match(nodes.map(textOf).join(' '), /Nothing under this Project yet/)
})
Loading
Loading