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/2521-overlay-readonly-reload.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
In a read-only window the Board, the New chat pane and the Project settings panel now draw the host's sentence with a **Reload** beside it: each is a full-screen overlay that covers the sidebar's own entry, so the reader had to close it to answer the sentence at all. The entry runs the client's own team read (never a page reload), one per overlay, and a writable window is unchanged.
18 changes: 10 additions & 8 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -9872,7 +9872,7 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, a
)) : plain.map((task) => (0, import_react17.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, void 0, locked) }))
);
}
function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) {
function BoardView({ roster, actions, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) {
const columns = boardColumns(tasks);
const lanes = scope?.kind === "group";
const selectedBot = scope?.kind === "bot" ? scope.botId : "";
Expand Down Expand Up @@ -9914,8 +9914,9 @@ function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, erro
(0, import_react17.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, t("Add task"))
) : null,
// #2273: the board is a 1080px overlay and covers the sidebar banner and the Settings hint,
// so the lock's own sentence is drawn here too, from the host, not in a second wording.
locked ? (0, import_react17.createElement)("p", { className: "bt-board-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null,
// so the lock's own sentence is drawn here too, from the host, not in a second wording —
// with the shared Reload beside it, since the sidebar's entry is covered by this overlay.
locked ? readOnlyLine(import_react17.createElement, roster, actions, { key: "readonly", className: "bt-board-hint" }) : null,
(0, import_react17.createElement)(
"div",
{ className: "bt-board-columns" },
Expand Down Expand Up @@ -10030,6 +10031,7 @@ function KanbanDialog({ roster, actions, leaving = false }) {
),
(0, import_react17.createElement)(BoardView, {
roster,
actions,
tasks: boardTasks,
rail,
scope,
Expand Down Expand Up @@ -12129,7 +12131,7 @@ function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, me
(0, import_react21.createElement)(ProjectBoardRecord, { record: view.record, projectId: view.projectId, call: call4 })
);
}
function ProjectSettingsPanel({ project, assistantMine, call: request, onClose, onSaved, locked = false, readOnly = "" } = {}) {
function ProjectSettingsPanel({ project, assistantMine, actions, call: request, onClose, onSaved, locked = false, readOnly = "" } = {}) {
const save = request ?? call;
const [kind, setKind] = (0, import_react21.useState)("bot");
const [memberId, setMemberId] = (0, import_react21.useState)("");
Expand All @@ -12145,7 +12147,7 @@ function ProjectSettingsPanel({ project, assistantMine, call: request, onClose,
"div",
{ className: "bt-project-panel", "data-panel": "project-settings" },
(0, import_react21.createElement)("h3", null, t("Project settings")),
(0, import_react21.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(readOnly)),
readOnlyLine(import_react21.createElement, { readOnly }, actions, { key: "readonly" }),
(0, import_react21.createElement)(
"div",
{ className: "bt-project-row-actions" },
Expand Down Expand Up @@ -12432,7 +12434,7 @@ function ProjectBoardOverlay({ actions, call: request, useRoster } = {}) {
close();
actions?.openSession?.(id);
}
}) : panel === "settings" ? (0, import_react21.createElement)(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, call: read, locked, readOnly: roster?.readOnly, onClose: () => setPanel(null) }) : panel === "members" ? (0, import_react21.createElement)(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : (0, import_react21.createElement)(ProjectBoardPane, {
}) : panel === "settings" ? (0, import_react21.createElement)(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, actions, call: read, locked, readOnly: roster?.readOnly, onClose: () => setPanel(null) }) : panel === "members" ? (0, import_react21.createElement)(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : (0, import_react21.createElement)(ProjectBoardPane, {
project: state.project,
rows: state.rows,
done: state.done,
Expand Down Expand Up @@ -16293,7 +16295,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES });
} else if (event.key === "Backspace" && query === "") unpick();
}
}),
group && admin !== null ? locked ? (0, import_react34.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : (0, import_react34.createElement)("button", {
group && admin !== null ? locked ? readOnlyLine(import_react34.createElement, roster, actions, { key: "readonly-admin" }) : (0, import_react34.createElement)("button", {
type: "button",
className: "bt-send bt-to-create",
disabled: busy || picked.length === 0,
Expand Down Expand Up @@ -16360,7 +16362,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES });
}
}
}),
locked ? group && admin !== null ? null : (0, import_react34.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : (0, import_react34.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react34.createElement)(ArrowUpIcon))
locked ? group && admin !== null ? null : readOnlyLine(import_react34.createElement, roster, actions, { key: "readonly-compose" }) : (0, import_react34.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react34.createElement)(ArrowUpIcon))
)
)
)
Expand Down
12 changes: 7 additions & 5 deletions core/src/client/kanban.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { taskCard } from '../shared/board-card.js'
import { BoardCard as BoardCardFrame, TASK_SKIN } from './board-card.js'
import { CloseIcon } from './icons.js'
import { useEscape } from './overlay-hooks.js'
import { hostText, t } from './i18n.js'
import { t } from './i18n.js'
import { readOnlyLine } from './read-only.js'
import { relativeShort } from './text.js'

const POLL_MS = 15_000
Expand Down Expand Up @@ -334,7 +335,7 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, a
: plain.map(task => h(BoardCard, { key: task.id, ...cardProps(task, roster, onMove, onDrag, undefined, locked) })))
}

export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) {
export function BoardView({ roster, actions, tasks, rail, scope, counts, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) {
const columns = boardColumns(tasks)
const lanes = scope?.kind === 'group'
const selectedBot = scope?.kind === 'bot' ? scope.botId : ''
Expand Down Expand Up @@ -367,8 +368,9 @@ export function BoardView({ roster, tasks, rail, scope, counts, dragging, error,
h('input', { 'aria-label': t('Task title'), placeholder: t('New task'), value: draft ?? '', onChange: (event) => onDraft?.(event.target.value) }),
h('button', { type: 'submit', className: 'bt-soft bt-soft-sm' }, t('Add task'))) : null,
// #2273: the board is a 1080px overlay and covers the sidebar banner and the Settings hint,
// so the lock's own sentence is drawn here too, from the host, not in a second wording.
locked ? h('p', { className: 'bt-board-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) : null,
// so the lock's own sentence is drawn here too, from the host, not in a second wording —
// with the shared Reload beside it, since the sidebar's entry is covered by this overlay.
locked ? readOnlyLine(h, roster, actions, { key: 'readonly', className: 'bt-board-hint' }) : null,
h('div', { className: 'bt-board-columns' },
['not-started', 'in-progress', 'done'].map(column => h(BoardColumn, {
key: column, column, tasks: columns[column], roster, lanes, dragging, onMove, onDrag, archiveFull, locked,
Expand Down Expand Up @@ -468,7 +470,7 @@ export function KanbanDialog({ roster, actions, leaving = false }) {
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, {
roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft, archiveFull, locked,
roster, actions, tasks: boardTasks, rail, scope, counts, dragging, error, draft, archiveFull, locked,
onDraft: setDraft, onSelect: setScope, onMove: move, onDrag: setDragging, onCreate: create,
})))
}
Expand Down
7 changes: 4 additions & 3 deletions core/src/client/new-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { useEscape, usePaneLeft } from './overlay-hooks.js'
import { CreateBot } from './create-bot.js'
import { afterCreate } from './bot-draft.js'
import { QUICK_EDGES, useFlip, useGlide, useMorph } from './motion.js'
import { hostText, t } from './i18n.js'
import { t } from './i18n.js'
import { readOnlyLine } from './read-only.js'
import { lockedWindow } from './schedules.js'
import { newChatActionRows } from './flybot-registry.js'
import { SlotIcon } from './flybot-slots.js'
Expand Down Expand Up @@ -251,7 +252,7 @@ export function NewChat({ mode, roster, actions }) {
},
}),
group && admin !== null ? (locked
? h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly))
? readOnlyLine(h, roster, actions, { key: 'readonly-admin' })
: h('button', {
type: 'button', className: 'bt-send bt-to-create', disabled: busy || picked.length === 0,
title: picked.length === 0 ? t('Add at least one more Bot') : undefined, onClick: createGroup,
Expand Down Expand Up @@ -299,6 +300,6 @@ export function NewChat({ mode, roster, actions }) {
locked
? (group && admin !== null
? null
: h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)))
: readOnlyLine(h, roster, actions, { key: 'readonly-compose' }))
: h('button', { type: 'button', className: 'bt-nc-send', 'aria-label': t('Send message'), disabled: busy || message.trim() === '' || picked.length === 0, onClick: sendMessage }, h(ArrowUpIcon))))))
}
7 changes: 4 additions & 3 deletions core/src/client/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { createElement as h, useCallback, useEffect, useRef, useState } from 'react'
import { call } from './api.js'
import { dateLocale, hostText, t } from './i18n.js'
import { readOnlyLine } from './read-only.js'
import { roleText } from './role-labels.js'
import { lockedWindow } from './schedules.js'

Expand Down Expand Up @@ -843,7 +844,7 @@ export function ProjectBoardPane({ project, rows, done, canManage, tasks, decisi
// unbind a member (`project-bind`), the assistant's per-owner controls
// (`project-assistant-owner`) and the assistant's memory (`project-memory-write`) — and adds no
// fourth. Fields the host has no write path for are not drawn, so every control here can save.
export function ProjectSettingsPanel({ project, assistantMine, call: request, onClose, onSaved, locked = false, readOnly = '' } = {}) {
export function ProjectSettingsPanel({ project, assistantMine, actions, call: request, onClose, onSaved, locked = false, readOnly = '' } = {}) {
const save = request ?? call
const [kind, setKind] = useState('bot')
const [memberId, setMemberId] = useState('')
Expand All @@ -867,7 +868,7 @@ export function ProjectSettingsPanel({ project, assistantMine, call: request, on
if (locked) {
return h('div', { className: 'bt-project-panel', 'data-panel': 'project-settings' },
h('h3', null, t('Project settings')),
h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(readOnly)),
readOnlyLine(h, { readOnly }, actions, { key: 'readonly' }),
h('div', { className: 'bt-project-row-actions' },
h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => onClose?.() }, t('Back to board'))))
}
Expand Down Expand Up @@ -1141,7 +1142,7 @@ export function ProjectBoardOverlay({ actions, call: request, useRoster } = {})
onClose: () => setPanel(null),
onOpenBot: (id) => { close(); actions?.openSession?.(id) },
})
: panel === 'settings' ? h(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, call: read, locked, readOnly: roster?.readOnly, onClose: () => setPanel(null) })
: panel === 'settings' ? h(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, actions, call: read, locked, readOnly: roster?.readOnly, onClose: () => setPanel(null) })
: panel === 'members' ? h(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) })
: h(ProjectBoardPane, {
project: state.project, rows: state.rows, done: state.done, canManage: state.canManage, tasks: state.tasks,
Expand Down
47 changes: 45 additions & 2 deletions core/test/readonly-reload-lines.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ const { outputFiles } = await build({
"export { DetailsDrawer } from './src/client/details-drawer.js'",
"export { InboxDialog } from './src/client/inbox.js'",
"export { MemoryDialog } from './src/client/memory-panel.js'",
"export { KanbanDialog } from './src/client/kanban.js'",
"export { NewChat } from './src/client/new-chat.js'",
"export { ProjectSettingsPanel } from './src/client/project-board.js'",
].join('\n'),
resolveDir: fileURLToPath(new URL('..', import.meta.url)),
},
Expand Down Expand Up @@ -61,7 +64,7 @@ runInNewContext(outputFiles[0].text, {
throw new Error(`Unexpected platform module ${name}`)
},
})
const { SettingsDialog, DetailsDrawer, InboxDialog, MemoryDialog } = module.exports
const { SettingsDialog, DetailsDrawer, InboxDialog, MemoryDialog, KanbanDialog, NewChat, ProjectSettingsPanel } = 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.'

Expand Down Expand Up @@ -140,6 +143,38 @@ const mountMemory = (roster, acts = actions()) => {
index = 0
return draw(MemoryDialog({ bot: chief, roster, actions: acts }))
}
// The three overlay surfaces the host sentence reaches without a way to answer it: the Board, the
// New chat overlay (which draws its sentence in the To: row once the admin is chosen and in the
// composer otherwise) and the Project settings panel.
const mountBoard = (roster, acts = actions()) => {
slots = []
index = 0
return draw(KanbanDialog({ roster, actions: acts }))
}
const mountNewChatComposer = (roster, acts = actions()) => {
slots = []
index = 0
return draw(NewChat({ mode: 'group', roster, actions: acts }))
}
// A locked group chat without an admin draws the sentence in the composer; with the admin chosen
// it draws it in the To: row instead. Seed the state that pick leaves behind (group on, admin
// chosen) so the second draw is the one under test.
const mountNewChatAdmin = (roster, acts = actions()) => {
slots = []
index = 0
slots[2] = true
slots[4] = 'chief'
return draw(NewChat({ mode: 'group', roster, actions: acts }))
}
const mountProjectSettings = (roster, acts = actions()) => {
slots = []
index = 0
const readOnly = typeof roster?.readOnly === 'string' ? roster.readOnly : ''
return draw(ProjectSettingsPanel({
project: { id: 'p1', name: 'Ops' }, call: () => Promise.resolve({}),
locked: readOnly.trim() !== '', readOnly, actions: acts, onClose() {},
}))
}

const PAGES = ['general', 'bots', 'groups', 'projects', 'connectors', 'capabilities', 'schedules', 'workflows', 'usage', 'secrets', 'feedback', 'about']

Expand All @@ -152,6 +187,10 @@ const SURFACES = [
{ name: 'drawer: group settings', mount: roster => mountDrawer(roster, 'settings', 'r1') },
{ name: 'Inbox dialog', mount: roster => mountInbox(roster) },
{ name: 'Memory panel', mount: roster => mountMemory(roster) },
{ name: 'Board dialog', mount: (roster, acts) => mountBoard(roster, acts) },
{ name: 'New chat: composer', mount: (roster, acts) => mountNewChatComposer(roster, acts) },
{ name: 'New chat: admin chosen', mount: (roster, acts) => mountNewChatAdmin(roster, acts) },
{ name: 'Project settings panel', mount: (roster, acts) => mountProjectSettings(roster, acts) },
]

test('the Settings header offers one Reload beside the host sentence on every page', () => {
Expand All @@ -174,7 +213,7 @@ test('each drawer surface offers exactly one Reload, never two', () => {
}
})

test('the Inbox and the Memory panel each offer one Reload — their own way out of the modal', () => {
test('every surface that is not the header or a drawer offers one Reload — its own way out of the modal', () => {
for (const surface of SURFACES.filter(entry => !entry.name.startsWith('drawer') && entry.name !== 'Settings header')) {
const nodes = surface.mount(locked)
assert.equal(sentenceNodes(nodes).length, 1, `${surface.name}: the sentence stays once`)
Expand Down Expand Up @@ -219,6 +258,10 @@ const mountFor = (surface, roster, acts) => {
if (surface.name === 'Settings header') return mountSettings(roster, 'general', null, acts)
if (surface.name === 'Inbox dialog') return mountInbox(roster, acts)
if (surface.name === 'Memory panel') return mountMemory(roster, acts)
if (surface.name === 'Board dialog') return mountBoard(roster, acts)
if (surface.name === 'New chat: composer') return mountNewChatComposer(roster, acts)
if (surface.name === 'New chat: admin chosen') return mountNewChatAdmin(roster, acts)
if (surface.name === 'Project settings panel') return mountProjectSettings(roster, acts)
return mountDrawer(roster, surface.name.includes('settings') ? 'settings' : 'details', surface.name.includes('group') ? 'r1' : 'chief', acts)
}

Expand Down
Loading
Loading