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/2276-memory-panel-readonly.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
In a read-only window a Bot's Memory panel no longer draws the **Remove** button or the **Ask or update** form the host would refuse, and says why in the host's own sentence, while the entries and **Regenerate summary** keep working ([#2276](https://github.com/ThinkFlowLab/FlyBot/issues/2276)).
1 change: 1 addition & 0 deletions changelog.d/2279-details-bot-settings-readonly.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
In a read-only window a Bot's details drawer no longer draws the Settings form, which the host would refuse, and says why in the host's own sentence instead; a writable window keeps the form unchanged ([#2279](https://github.com/ThinkFlowLab/FlyBot/issues/2279)).
1 change: 1 addition & 0 deletions changelog.d/2283-questions-readonly.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
In a read-only window the chat's `ask_user` card no longer draws an answer control, and the answers a card already holds stay readable ([#2283](https://github.com/ThinkFlowLab/FlyBot/issues/2283)).
13,520 changes: 6,762 additions & 6,758 deletions core/client.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion core/src/client/cells.js
Original file line number Diff line number Diff line change
Expand Up @@ -1109,7 +1109,7 @@ function renderCall(call, { sessionId, roster, actions, useUi, findBot }) {
const question = roster.questions?.[sessionId]
// Answered or closed: the card leaves no gap where it was.
if (!question || question.callId !== call.callId) return h('span', { className: 'bt-q-gone', hidden: true })
return h(PendingQuestion, { sessionId, question, actions, inline: true })
return h(PendingQuestion, { sessionId, question, actions, roster, inline: true })
}
case 'request_secret':
return h(SecretToolCell, { sessionId, callId: call.callId, name: arg('name'), done, content: call.content, roster, actions })
Expand Down
8 changes: 5 additions & 3 deletions core/src/client/details-drawer.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import { FaceLead, FacePersonRow, GroupSettingsForm, ROOM_MODES, facePeople, roo
import { ModelMenu, modelOf } from './model-menu.js'
import { FolderMenu } from './folder-menu.js'
import { MemoryRow } from './memory-panel.js'
import { RoomSchedules, ScheduleSummary } from './schedules.js'
import { RoomSchedules, ScheduleSummary, lockedWindow } from './schedules.js'
import { Segmented, useGlide, useMorph } from './motion.js'
import { MemberWork, useMemberWork } from './member-status.js'
import { t } from './i18n.js'
import { hostText, t } from './i18n.js'
import { remoteBotTitle, roomTitle, copiedToClipboard } from '../shared/remote-label.js'
import { FlyBotRoomOverview, FlyBotDetails } from './flybot-slots.js'
import { call } from './api.js'
Expand Down Expand Up @@ -267,7 +267,9 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro
h('span', { className: 'bt-subhead-title' }, bot ? t('Settings') : t('Group settings')),
hasStrip ? null : h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close details'), onClick: close }, h(CloseIcon))),
h('div', { key: 'set-body', className: 'bt-drawer-body bt-set-body', 'data-move': move }, errorLine,
bot ? h(BotSettingsForm, { bot, roster, actions, run }) : h(GroupSettingsForm, { room, roster, actions, run, onDeleted: close })),
bot && lockedWindow(roster)
? h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly))
: bot ? h(BotSettingsForm, { bot, roster, actions, run }) : h(GroupSettingsForm, { room, roster, actions, run, onDeleted: close })),
]
}
const top = h('div', { className: 'bt-drawer-top', 'data-move': side },
Expand Down
14 changes: 9 additions & 5 deletions core/src/client/memory-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,10 @@ function EntryRow({ entry, remove }) {
h('div', { className: 'bt-mem-entry-copy' },
h('span', { className: 'bt-mem-entry-text' }, entry.text),
meta ? h('span', { className: 'bt-mem-entry-meta' }, meta) : null),
h('button', {
remove ? h('button', {
type: 'button', className: 'bt-mem-remove', 'data-confirm': confirming || undefined, disabled: busy,
'aria-label': confirming ? t('Click again to remove') : t('Remove from memory'), title: confirming ? undefined : t('Remove from memory'), onClick: click,
}, confirming ? t('Remove') : h(TrashIcon)))
}, confirming ? t('Remove') : h(TrashIcon)) : null)
}

function Entries({ bot, view, remove }) {
Expand Down Expand Up @@ -208,8 +208,11 @@ function useNow(every) {
return now
}

export function MemoryDialog({ bot, actions, leaving = false }) {
export function MemoryDialog({ bot, actions, roster, leaving = false }) {
const { view, error, load, show } = useMemoryView(bot.id, actions)
// A read-only window refuses both writes below at the host, so the panel draws neither and
// says why in the host's own sentence — the same one the sidebar and the Settings header carry.
const locked = typeof roster?.readOnly === 'string' && roster.readOnly.trim() !== ''
const [page, setPage] = useState('summary')
const [menu, setMenu] = useState(false)
const [menuShown, menuLeaving] = useLinger(menu, MENU_EXIT_MS)
Expand Down Expand Up @@ -288,13 +291,14 @@ export function MemoryDialog({ bot, actions, leaving = false }) {
h('button', { type: 'button', role: 'menuitem', disabled: count === 0 || view?.summarizing, onClick: regenerate },
h('span', { className: 'bt-menu-label' }, t('Regenerate summary')))) : null),
h('div', { className: 'bt-mem-body' },
locked ? h('p', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) : null,
error ? h('div', { className: 'bt-error', role: 'alert', style: { padding: 0 } }, hostText(error)) : null,
view === null ? (error ? null : h(Skeleton))
: page === 'entries' ? h(Entries, { bot, view, remove })
: page === 'entries' ? h(Entries, { bot, view, remove: locked ? null : remove })
: h(Summary, { bot, view, regenerate })),
h('div', { className: 'bt-mem-foot' },
answer ? h(Reply, { answer, dismiss: () => setAnswer(null) }) : null,
h('form', { className: 'bt-mem-ask', onSubmit: ask },
locked ? null : h('form', { className: 'bt-mem-ask', onSubmit: ask },
h('input', {
ref: inputRef, className: 'bt-mem-input', value: text, maxLength: 2000, disabled: view === null,
placeholder: t('Ask or update'), 'aria-label': t("Ask about or update {name}'s memory", { name: bot.name }),
Expand Down
2 changes: 1 addition & 1 deletion core/src/client/overlays.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useA
menu ? createPortal(h(ContextMenu, { key: `${menu.kind ?? ''}:${menu.id}:${menu.x}:${menu.y}`, menu, roster, actions, leaving: menuLeaving }), document.body) : null,
settings ? createPortal(h(SettingsDialog, { page: settings.page, id: settings.id, roster, actions, useUpdate, leaving: settingsLeaving }), document.body) : null,
remoteBotSettings ? createPortal(h(RemoteBotSettingsSheet, { key: remoteBotSettings, bot: roster.byId[remoteBotSettings], roster, actions, leaving: remoteLeaving }), document.body) : null,
memory && roster.byId[memory] ? createPortal(h(MemoryDialog, { key: memory, bot: roster.byId[memory], actions, leaving: memoryLeaving }), document.body) : null,
memory && roster.byId[memory] ? createPortal(h(MemoryDialog, { key: memory, bot: roster.byId[memory], roster, actions, leaving: memoryLeaving }), document.body) : null,
inbox ? createPortal(h(InboxDialog, { roster, useWork, useSessions, actions, leaving: inboxLeaving }), document.body) : null,
board ? createPortal(h(KanbanDialog, { roster, actions, leaving: boardLeaving }), document.body) : null)
}
62 changes: 38 additions & 24 deletions core/src/client/questions.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createElement as h, useEffect, useId, useRef, useState } from 'react'
import { CloseIcon, CheckIcon } from './icons.js'
import { t } from './i18n.js'
import { hostText, t } from './i18n.js'
import { lockedWindow } from './schedules.js'
import { historyLoading } from './text.js'

// -------------------------------------------------------------------------
Expand Down Expand Up @@ -40,7 +41,7 @@ function useOptionKeys(count, onPick, ownRef) {

// A question shows two ways: as a bubble in the transcript, and raised when it
// sits in the dock above the composer.
function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) {
function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false, locked = false }) {
const [own, setOwn] = useState('')
const [picked, setPicked] = useState([])
const ownRef = useRef(null)
Expand All @@ -60,9 +61,9 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised
if (!canSubmit) return
onAnswer({ selected: multi ? picked : [], ...(custom ? { custom } : {}) })
}
useOptionKeys(options.length, index => choose(options[index].label), ownRef)
useOptionKeys(locked ? 0 : options.length, index => choose(options[index].label), ownRef)
const ownLetter = options.length > 0 ? keyLetter(options.length) : undefined
const field = ownAnswer ? h('textarea', {
const field = ownAnswer && !locked ? h('textarea', {
ref: ownRef, rows: 1, value: own, autoComplete: 'off', spellCheck: false, className: 'bt-q-input',
'aria-label': t('Your own answer'), placeholder: t('Type your own answer'),
'aria-keyshortcuts': ownLetter?.toLowerCase(),
Expand All @@ -75,17 +76,23 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised
}) : null
const rows = options.map((option, optionIndex) => {
const selected = multi && picked.includes(option.label)
return h('button', {
key: option.label, type: 'button', className: 'bt-q-opt',
'aria-pressed': multi ? selected : undefined,
'aria-keyshortcuts': keyLetter(optionIndex).toLowerCase(),
onClick: () => choose(option.label),
},
h('span', { className: 'bt-key', 'aria-hidden': true }, keyLetter(optionIndex)),
h('span', { className: 'bt-q-body' },
h('span', { className: 'bt-q-label' }, option.label),
option.description ? h('span', { className: 'bt-q-desc' }, option.description) : null),
selected && !raised ? h('span', { className: 'bt-q-check', title: t('Selected') }, h(CheckIcon)) : null)
const body = [
h('span', { className: 'bt-key', 'aria-hidden': true }, keyLetter(optionIndex)),
h('span', { className: 'bt-q-body' },
h('span', { className: 'bt-q-label' }, option.label),
option.description ? h('span', { className: 'bt-q-desc' }, option.description) : null),
selected && !raised ? h('span', { className: 'bt-q-check', title: t('Selected') }, h(CheckIcon)) : null,
]
// A read-only window keeps the question and its rows as text: an option answers at once on a
// single-select card, so a button there is a write the host refuses (#2277).
return locked
? h('div', { key: option.label, className: 'bt-q-opt' }, ...body)
: h('button', {
key: option.label, type: 'button', className: 'bt-q-opt',
'aria-pressed': multi ? selected : undefined,
'aria-keyshortcuts': keyLetter(optionIndex).toLowerCase(),
onClick: () => choose(option.label),
}, ...body)
})
// Raised cards keep the custom answer as one more keyed row; bubbles put it below the list.
if (raised && field) {
Expand All @@ -94,23 +101,24 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised
h('span', { className: 'bt-q-body' }, field)))
}
let foot = null
if (raised && multi) {
if (!locked && raised && multi) {
foot = h('div', { className: 'bt-q-foot' },
h('button', { type: 'button', className: 'bt-q-btn', onClick: onDismiss }, t('Skip')),
h('button', { type: 'submit', className: 'bt-q-submit', disabled: !canSubmit }, t('Submit')))
} else if ((raised || multi) && canSubmit) {
} else if (!locked && (raised || multi) && canSubmit) {
foot = h('div', { className: 'bt-q-foot' }, h('button', { type: 'submit', className: 'bt-q-submit' }, t('Submit')))
}
return h('form', {
// A read-only card is a plain box, not a form: it has nothing to submit, so it draws no handler.
return h(locked ? 'div' : 'form', {
className: raised ? 'bt-q bt-q-raised' : 'bt-q', 'aria-labelledby': titleId,
onSubmit: (event) => { event.preventDefault(); submit() },
...(locked ? {} : { onSubmit: (event) => { event.preventDefault(); submit() } }),
},
h('div', { className: 'bt-q-head' },
h('div', { className: 'bt-q-text' },
h('p', { className: 'bt-q-title', id: titleId }, question.header ? `${question.header} · ` : '', question.question),
question.detail ? h('div', { className: 'bt-q-detail' }, question.detail) : null,
raised && multi ? h('div', { className: 'bt-q-detail' }, t('Choose all that apply')) : null),
h('button', { type: 'button', className: 'bt-q-x', 'aria-label': t('Dismiss question'), title: t('Dismiss'), onClick: onDismiss }, h(CloseIcon))),
locked ? null : h('button', { type: 'button', className: 'bt-q-x', 'aria-label': t('Dismiss question'), title: t('Dismiss'), onClick: onDismiss }, h(CloseIcon))),
rows.length > 0 ? h('div', { className: 'bt-q-list', role: 'group' }, rows) : null,
field && !raised ? h('div', { className: 'bt-q-custom' },
h('label', { className: 'bt-q-field' }, field),
Expand Down Expand Up @@ -159,9 +167,10 @@ export function QuestionCard({ matched }) {
return h(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true })
}

export function PendingQuestion({ sessionId, question, actions, inline }) {
export function PendingQuestion({ sessionId, question, actions, roster, inline }) {
const [answered, setAnswered] = useState(null)
if (answered === question.id) return null
const locked = lockedWindow(roster)
const onAnswer = (answer) => {
setAnswered(question.id)
void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? '' }).catch(() => setAnswered(null))
Expand All @@ -171,17 +180,22 @@ export function PendingQuestion({ sessionId, question, actions, inline }) {
void actions.dismissQuestion(sessionId)
}
return h('div', { className: inline ? 'bt-q-inline' : 'bt-q-dock' },
h(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }))
// Both controls are writes the host refuses, and the refusal was invisible: the answer came
// back to a card that looked answered and the × hid the card until the next poll (#2277).
// A read-only window draws the question and the host's sentence, and no way to press either.
...(locked ? [h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly))] : []),
h(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline, locked }))
}

// Questions asked by a tool call render inline at that call (see ToolCell); the dock
// carries the ones whose call the transcript does not draw.
export function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) {
const question = useRoster(value => value.questions?.[sessionId])
const roster = useRoster(value => value)
const question = roster.questions?.[sessionId]
const drawn = useCards?.(state => state[sessionId]?.[question?.callId] > 0) === true
const loading = useSession?.(historyLoading) === true
if (question === undefined || (question.callId && (drawn || loading))) return null
return h(PendingQuestion, { sessionId, question, actions, inline: false })
return h(PendingQuestion, { sessionId, question, actions, roster, inline: false })
}

// An answered card in the transcript: the question stays where it was asked, with the
Expand Down
Loading
Loading