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/1828-project-roles.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Projects gain the three roles the governance matrix names, and a way in: the Owner is derived from `ownerUserIds` (an older record without `roles` reads owner-only), `project-role` sets a Maintainer or Member only after the host re-checks who may re-role whom, refusing `not-owner` / `cannot-self-promote` / `last-owner` without touching the record. The sidebar's **Projects** row now expands to the Projects you can see with your role there and opens that board straight, and its right-click **My role** menu offers only the ranks the host says you may switch to.
4,336 changes: 2,278 additions & 2,058 deletions core/client.js

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -1329,6 +1329,16 @@ const ZH = {
'e.g. Website': '例如:官网',
"Optional. Where this Project's work happens.": '可选。这个项目的工作目录。',
'Projects · {count}': '项目 · {count}',
// #1828: a Project's roles, and the sidebar row that lists them.
Maintainer: '维护者',
Member: '成员',
'No Projects yet': '还没有项目',
'Reading Projects…': '正在读取项目…',
'My role': '我的角色',
'No role to switch here.': '这里没有可切换的角色。',
'Role changed.': '角色已更改。',
'Role unchanged: {reason}': '角色未更改:{reason}',
refused: '被拒绝',
'No Project yet — create one.': '还没有项目——新建一个。',
'Bind a Bot or group': '绑定 Bot 或群聊',
'Bind to {name}': '绑定到 {name}',
Expand Down
2 changes: 2 additions & 0 deletions core/src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ 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 { PROJECTS_NAV_CSS } from './projects-nav.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 @@ -893,6 +894,7 @@ export function apply(ctx) {
installCompiledStyles(MOBILE_CSS, 'mobile'),
installCompiledStyles(PANE_MIN_WIDTH_CSS, 'panes'),
installCompiledStyles(PROJECT_BOARD_CSS, 'project-board'),
installCompiledStyles(PROJECTS_NAV_CSS, 'projects-nav'),
typeof document !== 'undefined' ? watchShellLevel(document) : null,
browsers ? installCompiledStyles(BROWSER_CSS, 'browser') : null,
installTheme(),
Expand Down
191 changes: 191 additions & 0 deletions core/src/client/projects-nav.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// #1828: Projects as a first-level sidebar entry. The row expands to the Projects this caller can
// see, each with the role they hold there; a row opens that board straight (`#project=<id>`), and
// the right-click menu offers only the roles the host says the caller may switch to, with the
// current one marked. The policy itself lives in the host (`roleChoices`/`changeRole`), so this
// module never decides who may switch what — it draws what the host answered and shows a refusal
// as the host worded it.
import { createElement as h, useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { call as callHost } from './api.js'
import { t } from './i18n.js'

const call = (endpoint, payload) => callHost(`flybot/${endpoint}`, payload)

export const PROJECTS_NAV_CSS = `
.bt-projects-row { display: flex; align-items: center; gap: 8px; }
.bt-projects-chevron { margin-left: auto; display: inline-flex; transition: transform 120ms ease; }
.bt-projects-row[aria-expanded="true"] .bt-projects-chevron { transform: rotate(90deg); }
.bt-projects-list { display: flex; flex-direction: column; margin: 0 0 4px; padding-left: 30px; }
.bt-project-entry { display: flex; align-items: baseline; gap: 8px; width: 100%; text-align: left; border: 0; background: none; color: inherit; font: inherit; padding: 4px 8px; border-radius: 6px; cursor: pointer; }
.bt-project-entry:hover, .bt-project-entry:focus-visible { background: var(--bt-hover, rgba(127,127,127,.14)); outline: none; }
.bt-project-entry[aria-current="true"] { background: var(--bt-hover, rgba(127,127,127,.14)); }
.bt-project-entry-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bt-project-entry-role { margin-left: auto; opacity: .62; font-size: .85em; white-space: nowrap; }
.bt-projects-empty, .bt-projects-note { padding: 2px 8px 6px; opacity: .7; font-size: .85em; }
.bt-project-menu { position: fixed; z-index: 60; min-width: 168px; padding: 4px; border-radius: 8px; background: var(--bt-menu-bg, #1f2126); border: 1px solid var(--bt-menu-border, rgba(255,255,255,.12)); box-shadow: 0 8px 24px rgba(0,0,0,.32); }
.bt-project-menu-item { display: flex; align-items: center; gap: 8px; width: 100%; border: 0; background: none; color: inherit; font: inherit; text-align: left; padding: 6px 8px; border-radius: 6px; cursor: pointer; }
.bt-project-menu-item:hover, .bt-project-menu-item:focus-visible { background: var(--bt-hover, rgba(127,127,127,.14)); outline: none; }
.bt-project-menu-item[aria-checked="true"]::before { content: "✓"; margin-right: 2px; }
.bt-project-menu-item:not([aria-checked="true"])::before { content: " "; margin-right: 2px; }
.bt-project-menu-item[disabled] { opacity: .6; cursor: default; }
`

const ROLE_LABELS = { owner: 'Owner', maintainer: 'Maintainer', member: 'Member' }

export function roleText(role) {
if (role === null || role === undefined || role === '') return ''
return t(ROLE_LABELS[role] ?? String(role))
}

// `#project=<id>` is the one entry point the board reads (`ProjectBoardOverlay`), so opening a row
// writes the hash instead of mounting a second copy of the board.
export function projectHash(id) {
return `#project=${encodeURIComponent(String(id ?? ''))}`
}

export function projectName(project) {
const name = typeof project?.name === 'string' ? project.name.trim() : ''
return name === '' ? `#${project?.id ?? ''}` : name
}

// The menu lists what the host said this caller may switch to, with the current rank marked. A
// Project written before roles existed answers `role: null`, so the row reads without a rank
// rather than guessing owner-only.
export function projectMenuItems(project) {
const current = project?.role ?? null
const choices = Array.isArray(project?.choices) ? project.choices : []
const items = current === null ? [] : [{ role: current, label: roleText(current), current: true, pick: false }]
for (const role of choices) {
if (role === current) continue
items.push({ role, label: roleText(role), current: false, pick: true })
}
return items
}

// A refusal names a machine-readable reason (`not-owner`, `cannot-self-promote`, `last-owner`); the
// reader gets the code back unchanged, as the brief asks, rather than a guess at its meaning.
export function roleNote(answer) {
if (answer?.changed === true) return t('Role changed.')
const reason = typeof answer?.reason === 'string' && answer.reason !== '' ? answer.reason : t('refused')
return t('Role unchanged: {reason}', { reason })
}

// The expanded list on its own, so a test can draw it without a fetch or a click.
export function ProjectList({ projects, error, note, onOpen, onRole }) {
const rows = projects === null
? h('div', { className: 'bt-projects-note' }, t('Reading Projects…'))
: error !== ''
? h('div', { className: 'bt-projects-note' }, error)
: projects.length === 0
? h('div', { className: 'bt-projects-empty' }, t('No Projects yet'))
: projects.map(project => h('button', {
key: String(project.id),
type: 'button', className: 'bt-project-entry', 'data-project-id': String(project.id),
'aria-current': project.current === true ? 'true' : undefined,
title: roleText(project.role) === '' ? projectName(project) : `${projectName(project)} · ${roleText(project.role)}`,
onClick: () => onOpen?.(project.id),
onContextMenu: (event) => { event.preventDefault(); onRole?.(project, event) },
},
h('span', { className: 'bt-project-entry-name' }, projectName(project)),
roleText(project.role) === '' ? null : h('span', { className: 'bt-project-entry-role' }, roleText(project.role))))
return h('div', { className: 'bt-projects-list', role: 'group', 'aria-label': t('Projects') },
rows,
note === '' || note === undefined ? null : h('div', { className: 'bt-projects-note', role: 'status' }, note))
}

function Chevron() {
return h('svg', { width: 12, height: 12, viewBox: '0 0 20 20', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
h('path', { d: 'M7.5 4.5L13 10l-5.5 5.5' }))
}

function ProjectsMark() {
return h('svg', { width: 18, height: 18, viewBox: '0 0 20 20', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinejoin: 'round', 'aria-hidden': true },
h('path', { d: 'M2.5 5.5h6l1.5 2h7.5v8a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1z' }),
h('path', { d: 'M2.5 5.5v-1a1 1 0 0 1 1-1h4l1.5 2' }))
}

// The menu is a portal at the cursor, closed by a click anywhere else, by Escape, and by picking.
export function ProjectRoleMenu({ project, x, y, onPick, onClose }) {
const ref = useRef(null)
useEffect(() => {
const onDown = (event) => { if (!ref.current?.contains(event.target)) onClose() }
const onKey = (event) => { if (event.key === 'Escape') onClose() }
window.addEventListener('mousedown', onDown, true)
window.addEventListener('keydown', onKey, true)
return () => { window.removeEventListener('mousedown', onDown, true); window.removeEventListener('keydown', onKey, true) }
}, [onClose])
const items = projectMenuItems(project)
// A member has nothing to switch: the menu still opens and marks the rank they hold, so the
// right-click answers "where am I" instead of doing nothing.
return createPortal(h('div', {
ref, className: 'bt-project-menu', role: 'menu', 'aria-label': t('My role'),
style: { left: `${x}px`, top: `${y}px` }, 'data-project-id': String(project?.id ?? ''),
},
h('div', { className: 'bt-projects-note' }, projectName(project)),
items.length === 0
? h('div', { className: 'bt-projects-note' }, t('No role to switch here.'))
: items.map(item => h('button', {
key: item.role, type: 'button', role: 'menuitemradio', className: 'bt-project-menu-item',
'aria-checked': item.current ? 'true' : 'false', disabled: !item.pick,
onClick: () => { if (item.pick) onPick(item.role); else onClose() },
}, item.label))), document.body)
}

export function ProjectsNav({ wide = true, request, onOpen } = {}) {
const read = request ?? call
const open = useCallback((id) => {
if (onOpen) { onOpen(id); return }
if (typeof window !== 'undefined') window.location.hash = projectHash(id)
}, [onOpen])
const [expanded, setExpanded] = useState(false)
const [projects, setProjects] = useState(null)
const [error, setError] = useState('')
const [note, setNote] = useState('')
const [menu, setMenu] = useState(null)
const load = useCallback(async () => {
try {
const listing = await read('projects')
const list = Array.isArray(listing?.projects) ? listing.projects : []
setProjects(list)
setError('')
} catch (failure) {
setProjects([])
setError(failure?.message ?? String(failure))
}
}, [read])
useEffect(() => {
if (!expanded) return
void load()
}, [expanded, load])
const toggle = () => {
setNote('')
if (!wide) { void open(''); return }
setExpanded(value => !value)
}
const listed = expanded && wide
return h('div', { className: 'bt-projects' },
h('button', {
type: 'button', className: 'bt-row bt-projects-row', 'aria-expanded': listed ? 'true' : 'false',
'aria-label': t('Projects'), title: wide ? undefined : t('Projects'), onClick: toggle,
},
h('span', { className: 'bt-projects-mark' }, h(ProjectsMark)),
wide ? h('span', { className: 'bt-projects-label' }, t('Projects')) : null,
wide ? h('span', { className: 'bt-projects-chevron', 'aria-hidden': true }, h(Chevron)) : null),
listed ? h(ProjectList, { projects, error, note, onOpen: open, onRole: (project, event) => setMenu({ project, x: event.clientX, y: event.clientY }) }) : null,
menu === null ? null : h(ProjectRoleMenu, {
project: menu.project, x: menu.x, y: menu.y,
onClose: () => setMenu(null),
onPick: async (role) => {
const project = menu.project
setMenu(null)
try {
const answer = await read('project-role', { id: project.id, role })
setNote(roleNote(answer))
if (answer?.changed === true) void load()
} catch (failure) {
// The host's sentence is the answer; quoting it beats a generic failure line.
setNote(failure?.message ?? String(failure))
}
},
}))
}
2 changes: 2 additions & 0 deletions core/src/client/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { visibleText } from '../shared/preview.js'
import { showsDshRow } from './dsh-offer.js'
import { InboxRow } from './inbox.js'
import { KanbanRow } from './kanban.js'
import { ProjectsNav } from './projects-nav.js'

// -------------------------------------------------------------------------
// Sidebar
Expand Down Expand Up @@ -395,6 +396,7 @@ export function TeamSidebar(props) {
wide && roster.readOnly ? h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) : null,
h(InboxRow, { wide, useRoster, useWork: props.useWork, useUi, actions }),
h(KanbanRow, { wide, useUi, actions }),
h(ProjectsNav, { wide }),
h('div', { ref: listRef, className: 'bt-list', role: 'region', 'aria-label': t('Bot list') },
tree ? h('div', { className: 'bt-tree', role: 'group', 'aria-label': t('Bot tree') }, tree.map(node => h(TreeNode, { key: node.bot.id, node, rowProps, markedUnread, collapsed, toggle, actions: structure?.nodeActions })))
: main ? h(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : h('div', { className: 'bt-hint' }, t('Setting up your Main Bot…')),
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-role':
case 'project-delete':
case 'project-memory':
case 'project-memory-write':
Expand Down
75 changes: 75 additions & 0 deletions core/src/host/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,74 @@ import { join } from 'node:path'
import { columnOf } from '../shared/task-board.js'
import { DECISIONS_DIR, listProjectState, sha256 } from './project-state.js'

// #1828: a Project's roles. The record stores only `roles: { maintainers, members }`; the owner
// is read from `ownerUserIds`, never stored twice, so the two cannot disagree. A record written
// before this slice carries no `roles` and derives to owner-only — no migration pass.
export const PROJECT_ROLE_RANKS = { member: 1, maintainer: 2, owner: 3 }
const roleList = value => (Array.isArray(value) ? [...new Set(value.map(id => text(id)).filter(id => id !== ''))] : [])
export function rolesOf(project) {
const owner = roleList(project?.ownerUserIds)
const primary = text(project?.ownerUserId)
if (primary !== '' && !owner.includes(primary)) owner.unshift(primary)
const stored = project?.roles !== null && typeof project?.roles === 'object' ? project.roles : {}
const maintainers = roleList(stored.maintainers).filter(id => !owner.includes(id))
const members = roleList(stored.members).filter(id => !owner.includes(id) && !maintainers.includes(id))
return { owner, maintainers, members }
}
export function roleOf(project, memberId) {
const id = text(memberId)
if (id === '') return null
const roles = rolesOf(project)
if (roles.owner.includes(id)) return 'owner'
if (roles.maintainers.includes(id)) return 'maintainer'
if (roles.members.includes(id)) return 'member'
return null
}
// The roles a caller may switch a target to: the current one is not repeated, and the Owner rank
// is never an assignment (transfer is a separate slice). A Member sees nothing, a Maintainer
// sees only Member, and an Owner acting on anyone else sees the two assignable ranks.
export function roleChoices(project, actorId, targetId = actorId) {
const actor = roleOf(project, actorId)
const self = text(targetId) === text(actorId)
const target = self ? actor : roleOf(project, targetId)
const choices = []
// The Owner of a Project never drops its own rank here: the menu shows only the current item.
if (self && actor === 'owner') return choices
for (const role of ['maintainer', 'member']) {
if (role === target) continue
if (actor === 'owner') choices.push(role)
else if (self && actor !== null && PROJECT_ROLE_RANKS[role] < PROJECT_ROLE_RANKS[actor]) choices.push(role)
}
return choices
}
// The policy Chief's matrix (2026-10-30) fixes, in one place the endpoint and the menu both read:
// only an Owner re-roles someone else, nobody promotes themselves, and a Project always keeps its
// Owner. Returns the next stored roles on success and a machine-readable reason on refusal; the
// caller's record is untouched either way.
export function changeRole(project, { actorId, targetId, role } = {}) {
const actor = roleOf(project, actorId)
const self = text(targetId) === '' || text(targetId) === text(actorId)
const target = self ? text(actorId) : text(targetId)
const wanted = text(role)
const current = rolesOf(project)
if (PROJECT_ROLE_RANKS[wanted] === undefined) return { ok: false, reason: 'unknown-role' }
if (wanted === 'owner') return { ok: false, reason: 'owner-not-assignable' }
if (actor === null) return { ok: false, reason: 'not-owner' }
if (self) {
if (actor === 'owner') return { ok: false, reason: 'last-owner' }
if (PROJECT_ROLE_RANKS[wanted] > PROJECT_ROLE_RANKS[actor]) return { ok: false, reason: 'cannot-self-promote' }
} else if (actor !== 'owner') {
return { ok: false, reason: 'not-owner' }
} else if (current.owner.includes(target)) {
return { ok: false, reason: 'last-owner' }
}
const maintainers = current.maintainers.filter(id => id !== target)
const members = current.members.filter(id => id !== target)
if (wanted === 'maintainer') maintainers.push(target)
else members.push(target)
return { ok: true, roles: { maintainers, members } }
}

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

// The queue, the leases and the blocked cards are host truth the board never copied (#1768):
Expand Down Expand Up @@ -364,6 +432,13 @@ export function install(rt) {
waiting.sort((a, b) => (a.since ?? Infinity) - (b.since ?? Infinity) || a.id.localeCompare(b.id))
return { waiting, inflight, blocked, counts: { waiting: waiting.length, inflight: inflight.length, blocked: blocked.length, done } }
}
// #1828: the endpoint the host calls for a role switch. It answers the policy's own decision;
// the store is what persists the two assignable lists.
rt.projectRole = (project, { actorId, targetId, role } = {}) => changeRole(project, { actorId, targetId, role })
// The listing's own read (#1828): the caller's rank and the ranks they may switch to. Both are
// the functions the endpoint uses, so the sidebar menu offers exactly what the host accepts.
rt.projectRoleOf = (project, memberId) => roleOf(project, memberId)
rt.projectRoleChoices = (project, memberId) => roleChoices(project, memberId)
rt.projectBoard = projectBoard
// The three groups are their own read over the same pass, for a caller that wants only the queue.
rt.projectBoardQueues = async (projectId, actor) => {
Expand Down
Loading
Loading