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/2400-group-overview-readonly.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A read-only window's group overview in the details drawer now draws no write control: the group's name is plain text rather than a rename button, an invited share no longer offers **Remove my Bot**, and a shared room's "Waiting for your approval" card keeps its headline and rows but drops **Approve** and **Reject**, with the host's own sentence in the single `role="status"` line at the top of the page ([#2400](https://github.com/ThinkFlowLab/FlyBot/issues/2400)). A writable window keeps every control unchanged.
10 changes: 7 additions & 3 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -19893,7 +19893,6 @@ function SharedRoomApprovals({ room: room2, roster }) {
locked ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "approve") }, t("Approve")),
locked ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "reject") }, t("Reject"))
)),
locked ? (0, import_react44.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null,
error ? (0, import_react44.createElement)("div", { className: "bt-error", role: "alert" }, error) : null
);
}
Expand Down Expand Up @@ -19928,15 +19927,20 @@ function GroupOverview({ room: room2, roster, actions, usage, useWork, openSetti
if (mine) void actions.updateRoom(room2.id, { removeParticipants: [{ kind: "bot", id: mine.handle || mine.id, remote: Boolean(mine.handle) }] });
};
const face = room2.origin === "face-to-face";
const locked = lockedWindow(roster);
return [
// P-544: the group overview's writes are gone in a locked window, so it says why once, above
// the reads it still draws. The approval card used to carry its own copy (#2381); one page,
// one sentence.
locked ? (0, import_react44.createElement)("div", { key: "read-only", className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null,
(0, import_react44.createElement)(FlyBotRoomOverview, { key: "plugin-overview", room: room2, roster, actions }),
invited && room2.sharedFrom && !face ? (0, import_react44.createElement)(
"div",
{ key: "from", className: "bt-shared-from" },
(0, import_react44.createElement)("div", { className: "bt-section-title" }, title),
(0, import_react44.createElement)("div", { className: "bt-set-hint" }, t("Your Bot was invited to this room by {name}", { name: room2.sharedFrom.inviterName })),
room2.sharedFrom.invitedAt ? (0, import_react44.createElement)("div", { className: "bt-set-hint" }, t("Invited {when}", { when: new Date(room2.sharedFrom.invitedAt).toLocaleString() })) : null,
(0, import_react44.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot"))
locked ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot"))
) : face ? null : room2.notice ? (0, import_react44.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react44.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")),
face ? null : (0, import_react44.createElement)(SharedRoomApprovals, { key: "approvals", room: room2, roster }),
face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : face ? (0, import_react44.createElement)(FaceMembers, { key: "members", room: room2, roster, actions }) : (0, import_react44.createElement)(
Expand Down Expand Up @@ -20119,7 +20123,7 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a
"div",
{ key: "id", className: "bt-drawer-id", "data-move": side },
bot ? (0, import_react44.createElement)(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== void 0), main: isMainBot, badge: false, gaze: true, pokeable: true }) : (0, import_react44.createElement)(RoomAvatar, { room: room2, roster, size: 64 }),
bot?.remote ? (0, import_react44.createElement)("div", { className: "bt-drawer-name" }, current) : bot && lockedWindow(roster) ? (0, import_react44.createElement)("div", { className: "bt-drawer-name", title: current }, current) : room2?.origin === "face-to-face" ? (0, import_react44.createElement)("div", { className: "bt-drawer-name bt-drawer-name-face", title: current }, current) : editing ? (0, import_react44.createElement)("input", {
bot?.remote ? (0, import_react44.createElement)("div", { className: "bt-drawer-name" }, current) : bot && lockedWindow(roster) ? (0, import_react44.createElement)("div", { className: "bt-drawer-name", title: current }, current) : room2?.origin === "face-to-face" ? (0, import_react44.createElement)("div", { className: "bt-drawer-name bt-drawer-name-face", title: current }, current) : room2 && lockedWindow(roster) ? (0, import_react44.createElement)("div", { className: "bt-drawer-name", title: current }, current) : editing ? (0, import_react44.createElement)("input", {
className: "bt-drawer-name-input",
autoFocus: true,
value: draftName,
Expand Down
22 changes: 13 additions & 9 deletions core/src/client/details-drawer.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,8 @@ function SharedRoomApprovals({ room, roster }) {
if ((room.viewerRole !== 'invited-owner' && !face) || !items.length) return null
// A decision is a write, so a read-only window cannot make one: the host would answer
// `bot/read-only` (or `bot/blocked-shared` in a room with several members) after the click.
// The card keeps its headline, count and rows; the two buttons give way to one line carrying the
// host's own sentence, the same shape the drawer's Settings view uses (#2381).
// The card keeps its headline, count and rows; its two buttons are gone, and the overview's one
// read-only sentence above explains why (#2381, P-544).
const locked = lockedWindow(roster)
const decide = async (item, decision) => {
setBusy(item.id)
Expand All @@ -150,9 +150,6 @@ function SharedRoomApprovals({ room, roster }) {
locked
? null
: h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id, onClick: () => decide(item, 'reject') }, t('Reject')))),
locked
? h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly))
: null,
error ? h('div', { className: 'bt-error', role: 'alert' }, error) : null)
}

Expand Down Expand Up @@ -184,14 +181,19 @@ function GroupOverview({ room, roster, actions, usage, useWork, openSettings })
if (mine) void actions.updateRoom(room.id, { removeParticipants: [{ kind: 'bot', id: mine.handle || mine.id, remote: Boolean(mine.handle) }] })
}
const face = room.origin === 'face-to-face'
const locked = lockedWindow(roster)
return [
// P-544: the group overview's writes are gone in a locked window, so it says why once, above
// the reads it still draws. The approval card used to carry its own copy (#2381); one page,
// one sentence.
locked ? h('div', { key: 'read-only', className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) : null,
h(FlyBotRoomOverview, { key: 'plugin-overview', room, roster, actions }),
invited && room.sharedFrom && !face
? h('div', { key: 'from', className: 'bt-shared-from' },
h('div', { className: 'bt-section-title' }, title),
h('div', { className: 'bt-set-hint' }, t('Your Bot was invited to this room by {name}', { name: room.sharedFrom.inviterName })),
room.sharedFrom.invitedAt ? h('div', { className: 'bt-set-hint' }, t('Invited {when}', { when: new Date(room.sharedFrom.invitedAt).toLocaleString() })) : null,
h('button', { type: 'button', className: 'bt-more', onClick: leaveMine }, t('Remove my Bot')))
locked ? null : h('button', { type: 'button', className: 'bt-more', onClick: leaveMine }, t('Remove my Bot')))
: face
? null
: room.notice
Expand Down Expand Up @@ -338,13 +340,15 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro
: h(RoomAvatar, { room, roster, size: 64 }),
bot?.remote
? h('div', { className: 'bt-drawer-name' }, current)
// P-542/P-487: a locked window refuses a rename like any other write, so the name stays as
// plain text instead of a control that can only be turned down. Bot overview only: a group's
// overview still holds its member entries, so it is left exactly as it was (P-544).
// P-542/P-487/P-544: a locked window refuses a rename like any other write, so the name
// stays as plain text instead of a control that can only be turned down — for a group as
// well as for a Bot.
: bot && lockedWindow(roster)
? h('div', { className: 'bt-drawer-name', title: current }, current)
: room?.origin === 'face-to-face'
? h('div', { className: 'bt-drawer-name bt-drawer-name-face', title: current }, current)
: room && lockedWindow(roster)
? h('div', { className: 'bt-drawer-name', title: current }, current)
: editing
? h('input', {
className: 'bt-drawer-name-input', autoFocus: true, value: draftName, 'aria-label': bot ? t('Bot name') : t('Group name'),
Expand Down
177 changes: 177 additions & 0 deletions core/test/details-group-overview-readonly.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// P-544: a read-only window's *group* overview still drew the rename control, left the name a
// button, and (for an invited share) offered `Remove my Bot` — every one of them a write the host
// answers with `bot/read-only`. It also said nothing about why. #2391 fixed the Bot overview and
// deliberately left this one alone; this is the group half.
//
// The drawing harness is the one `details-overview-readonly.test.js` uses: the real bundle, React
// and the host replaced by stubs, hook slots that survive a re-render, and no jsdom.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { fileURLToPath } from 'node:url'
import { runInNewContext } from 'node:vm'
import { build } from 'esbuild'

const { outputFiles } = await build({
stdin: {
contents: "export { DetailsDrawer } from './src/client/details-drawer.js'",
resolveDir: fileURLToPath(new URL('..', import.meta.url)),
},
bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent',
external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'],
})

const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat(Infinity) } })
let slots = []
let index = 0
let effects = []
const react = {
createElement: h, Component: class {}, Fragment: Symbol('F'),
useMemo: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn(); return slots[at] },
useState: (initial) => {
const at = index++
if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial
return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }]
},
useCallback: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn; return slots[at] },
useRef: (value) => { const at = index++; if (!(at in slots)) slots[at] = { current: value }; return slots[at] },
useEffect: (fn) => { const at = index++; if (!(at in slots)) { slots[at] = true; effects.push(fn) } },
useLayoutEffect() {},
useSyncExternalStore: (_subscribe, get) => get(),
}
const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) }
const module = { exports: {} }
runInNewContext(outputFiles[0].text, {
module, exports: module.exports, console, setTimeout, clearTimeout, requestAnimationFrame: () => 0, cancelAnimationFrame() {},
document: { hidden: false, addEventListener() {}, removeEventListener() {} },
navigator: { language: 'en' },
localStorage: { getItem: () => null, setItem() {} },
window: win,
require: (name) => {
if (name === 'react') return react
if (name === 'react-dom') return { createPortal: node => node }
if (name === '@deepseek-ai/dsh-client-ui-primitives') return {}
throw new Error(`Unexpected platform module ${name}`)
},
})
const { DetailsDrawer } = module.exports

const SENTENCE = 'The Bot team in /home/xuke/.flybot is open in another dsh (Web or Desktop, pid 41 on host). Close the other one and reload.'
const bot = { id: 'a', name: 'Chief', pinned: false, hidden: false, model: '', instructions: '', workDir: '' }
const room = {
id: 'r1', name: 'Team room', origin: 'group', named: true, members: ['a'], admin: 'a',
participants: [], presentedParticipants: [], pinned: false, hidden: false, notice: '',
workDir: '', mode: 'everyone', viewerRole: 'owner',
}
const invited = { ...room, viewerRole: 'invited-owner', pendingCount: 0, sharedFrom: { inviterName: 'Chris', invitedAt: 0 } }
const pending = { ...invited, pendingCount: 1, pendingApprovals: [{ id: 'a1', text: 'Which file?' }] }
const base = {
bots: [bot], byId: { a: bot }, prefs: {}, mainBotIds: ['a'], connectors: [], memory: false,
exchangeTurns: {}, questions: {}, self: { memberId: 'm1' },
}
const rosterFor = (target, readOnly) => ({
...base, rooms: [target], roomsById: { [target.id]: target },
...(readOnly === undefined ? {} : { readOnly }),
})
const actions = {
openSettings() {}, closeSettings() {}, closeOverlay() {}, openSession() {}, openBrowser() {},
notify() {}, reportError() {}, setFlags: () => Promise.resolve(),
updateBot: () => Promise.resolve(), updateRoom: () => Promise.resolve(),
schedules: () => Promise.resolve({ available: true, tasks: [] }),
}
const render = (roster, acts = actions) => {
slots = []
index = 0
effects = []
const map = () => new Map()
return DetailsDrawer({
sessionId: 'r1', startTab: 'details', hasStrip: false, roster, actions: acts,
useSessions: selector => selector({ byId: {}, projectionsBySession: {} }), useSessionStatus: selector => selector(map()),
useActivity: selector => selector(map()), useWork: selector => selector(map()),
useBrowserTabs: selector => selector(map()), useBrowserPages: selector => selector(map()),
})
}
// Real React draws a function component when it meets one; this expands the same way, with the hook
// slots above assigned in tree order.
const expand = (node) => {
if (Array.isArray(node)) return node.map(expand)
if (!node || typeof node !== 'object') return node
const props = node.props ?? {}
if (typeof node.type === 'function') return expand(node.type(props))
return { ...node, props: { ...props, children: expand(props.children) } }
}
const flatten = (node, ancestors = [], out = []) => {
if (Array.isArray(node)) { for (const child of node) flatten(child, ancestors, out); return out }
if (!node || typeof node !== 'object') return out
out.push({ node, ancestors })
flatten(node.props?.children, [...ancestors, node], out)
return out
}
const tree = (roster) => flatten(expand(render(roster)))
const ownText = (node) => {
const walk = (value) => {
if (Array.isArray(value)) return value.map(walk).join('')
if (typeof value === 'string' || typeof value === 'number') return String(value)
if (value && typeof value === 'object') return walk(value.props?.children)
return ''
}
return walk(node?.props?.children)
}
const hasClass = (node, name) => typeof node.props?.className === 'string' && node.props.className.split(' ').includes(name)
const bodyOf = entries => entries.find(({ node }) => node.props?.className === 'bt-drawer-body')
const renameButton = entries => entries.find(({ node }) => node.type === 'button' && hasClass(node, 'bt-drawer-name'))
const plainName = entries => entries.find(({ node }) => node.type === 'div' && hasClass(node, 'bt-drawer-name') && ownText(node).trim() === 'Team room')
const removeButton = entries => entries.find(({ node }) => node.type === 'button' && ownText(node).trim() === 'Remove my Bot')
const hintEntries = entries => entries.filter(({ node }) => hasClass(node, 'bt-read-only'))
const statusLine = entries => entries.filter(({ node }) => node.props?.role === 'status' && ownText(node).trim() === SENTENCE)
const buttonLabels = entries => entries.filter(({ node }) => node.type === 'button').map(({ node }) => ownText(node).trim())
const inBody = entries => {
const box = bodyOf(entries)
return box ? entries.filter(entry => entry.ancestors.includes(box.node)) : []
}

test('P-544: a read-only group overview says why once and draws no rename control', () => {
const locked = tree(rosterFor(room, SENTENCE))
const lines = statusLine(locked)
assert.equal(lines.length, 1, "the host's sentence is drawn once")
assert.ok(hasClass(lines[0].node, 'bt-read-only'), 'the sentence is the read-only hint')
const name = plainName(locked)?.node
assert.ok(name, 'the name is still drawn, as plain text')
assert.equal(name.props.title, 'Team room', 'the plain name keeps the full text as its title')
assert.deepEqual(renameButton(locked), undefined, 'pressing the name can no longer open the rename field')
const labels = buttonLabels(inBody(locked))
for (const write of ['Pin', 'Hide from sidebar', 'Remove my Bot']) {
assert.equal(labels.includes(write), false, `${write} is not drawn in a read-only group overview`)
}
})

test('P-544: a read-only invited group draws no Remove my Bot and keeps the invitation text', () => {
const locked = tree(rosterFor(invited, SENTENCE))
assert.deepEqual(removeButton(locked), undefined, 'Remove my Bot is a write the host would refuse')
assert.match(ownText(bodyOf(locked).node), /invited to this room by Chris/, 'the invitation itself is a read and stays')
assert.equal(statusLine(locked).length, 1, "the host's sentence is drawn once")
})

test('P-544: a writable group keeps the rename control, Remove my Bot and draws no sentence', () => {
const open = tree(rosterFor(invited, undefined))
assert.ok(renameButton(open), 'a writable group keeps the rename control')
assert.ok(removeButton(open), 'a writable invited group keeps Remove my Bot')
assert.deepEqual(statusLine(open), [], 'no read-only sentence is drawn')
for (const roster of [rosterFor(invited, ''), rosterFor(invited, ' ')]) {
const blank = tree(roster)
assert.ok(renameButton(blank), 'a blank read-only string is writable, as before')
assert.ok(removeButton(blank), 'a blank read-only string keeps Remove my Bot')
assert.deepEqual(statusLine(blank), [], 'a blank read-only string draws no sentence')
}
})

test('P-544: a read-only group with a pending approval still says the host sentence exactly once', () => {
const locked = tree(rosterFor(pending, SENTENCE))
const lines = hintEntries(locked)
assert.equal(lines.length, 1, 'one read-only line on the page, not one per card')
assert.equal(ownText(lines[0].node).trim(), SENTENCE, "the line is the host's own sentence")
assert.equal(lines[0].ancestors.some(node => hasClass(node, 'bt-shared-wait')), false,
'the page sentence is drawn above the approval card, not repeated inside it')
assert.match(ownText(bodyOf(locked).node), /Which file\?/, 'the approval is still readable')
const decisions = buttonLabels(locked).filter(label => label === 'Approve' || label === 'Reject')
assert.deepEqual(decisions, [], 'a read-only window still offers neither decision')
})
Loading
Loading