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
3,375 changes: 1,688 additions & 1,687 deletions core/client.js

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions core/src/client/composer.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ChatsIcon, ClockIcon, MicIcon, PaperclipIcon, PeopleIcon, PlusIcon, Wav
import { BotMark, markBotForMention } from './mark.js'
import { useLinger } from './motion.js'
import { t } from './i18n.js'
import { lockedWindow } from './schedules.js'

// -------------------------------------------------------------------------
// Composer: dictation mic and the voice-chat button. The shell's send
Expand Down Expand Up @@ -136,6 +137,9 @@ export function ComposerPlus({ sessionId, inputActions, useRoster, actions }) {
}, [open])
const target = roster.byId?.[sessionId]?.id ?? roster.roomsById?.[sessionId]?.id
if (sessionId === undefined || target === undefined) return null
// A locked window draws no create entry: "Scheduled task" opened the settings list with this
// chat's id, which asks for the create form, and a locked page draws no form (#2232, P-457).
const locked = lockedWindow(roster)
const toggle = () => {
if (open) { setOpen(null); return }
const rect = button.current.getBoundingClientRect()
Expand All @@ -145,10 +149,6 @@ export function ComposerPlus({ sessionId, inputActions, useRoster, actions }) {
setOpen(null)
button.current?.closest('[data-composer-card]')?.querySelector('input[type=file]')?.click()
}
const schedule = () => {
setOpen(null)
actions.openSettings('schedules', target)
}
// The chats open in the shell's "@" menu at the caret, so the pick lands where the
// user was typing.
const reference = () => {
Expand All @@ -167,5 +167,5 @@ export function ComposerPlus({ sessionId, inputActions, useRoster, actions }) {
},
h('button', { type: 'button', role: 'menuitem', onClick: attach }, h(PaperclipIcon), h('span', { className: 'bt-menu-label' }, t('Add files'))),
canReference ? h('button', { type: 'button', role: 'menuitem', onMouseDown: keepComposerFocus, onClick: reference }, h(ChatsIcon), h('span', { className: 'bt-menu-label' }, t('Reference a chat'))) : null,
h('button', { type: 'button', role: 'menuitem', onClick: schedule }, h(ClockIcon), h('span', { className: 'bt-menu-label' }, t('Scheduled task')))), document.body) : null)
locked ? null : h('button', { type: 'button', role: 'menuitem', onClick: () => { setOpen(null); actions.openSettings('schedules', target) } }, h(ClockIcon), h('span', { className: 'bt-menu-label' }, t('Scheduled task')))), document.body) : null)
}
2 changes: 1 addition & 1 deletion core/src/client/details-drawer.js
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro
roster.memory === false ? null : h(MemoryRow, { key: 'memory', bot, actions }),
usage ? h('div', { key: 'usage' }, h('div', { className: 'bt-section-title' }, t('Usage')),
h('div', { className: 'bt-kv' }, h('span', null, bot.name), h('span', null, usage))) : null,
h(ScheduleSummary, { key: 'routines', bot, actions }),
h(ScheduleSummary, { key: 'routines', bot, roster, actions }),
] : null,
bot && !bot.remote && tab === 'browser' ? h(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null)]
}
16 changes: 10 additions & 6 deletions core/src/client/schedules.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export function lastRunText(run) {

// The window lock, read the way the rest of the client reads it (#2232): the Settings header draws
// the sentence, and this page draws no write the host would refuse.
const lockedWindow = roster => typeof roster?.readOnly === 'string' && roster.readOnly.trim() !== ''
export const lockedWindow = roster => typeof roster?.readOnly === 'string' && roster.readOnly.trim() !== ''

function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster, locked }) {
const [confirming, setConfirming] = useState(false)
Expand Down Expand Up @@ -434,8 +434,9 @@ export function CopyRow({ task, roster, actions, onView, onCancel }) {
export function BotSchedules({ bot, roster, actions }) {
const view = useSchedules(actions)
const [editing, setEditing] = useState(null)
// A locked window keeps the list and its New task row: that row only opens the settings list,
// which draws no create control once this page respects the lock (#2232).
// A locked window keeps the list but draws no New task row: the row opened the settings list
// with this Bot's id, which asks for the create form, and a locked page draws no form — so the
// click landed on an empty page (#2232, P-457).
const locked = lockedWindow(roster)
const refused = schedulesReadFailure(view?.readFailure)
if (refused !== null) {
Expand All @@ -457,7 +458,7 @@ export function BotSchedules({ bot, roster, actions }) {
tasks, editing, roster, actions, locked, onEdit: locked ? undefined : setEditing,
onDone: (next) => { publish(next); setEditing(null) },
onCancel: () => setEditing(null),
extra: [h('button', { key: 'new', type: 'button', className: 'bt-set-row bt-bs-row bt-gs-add', onClick: () => actions.openSettings('schedules', bot.id) }, h(PlusIcon), t('New task'))],
extra: locked ? [] : [h('button', { key: 'new', type: 'button', className: 'bt-set-row bt-bs-row bt-gs-add', onClick: () => actions.openSettings('schedules', bot.id) }, h(PlusIcon), t('New task'))],
}))
}

Expand All @@ -478,15 +479,18 @@ function SummaryLines({ tasks, actions }) {
// The details panel's line: the Bot's next tasks, or how to add one. Paused and finished
// tasks are the Bot's tasks too, so only a Bot with none at all gets the empty state (#2024).
// The rows open the settings list, where a bot id would open the new-task form instead — the
// form is what the empty state's button means (#2030).
export function ScheduleSummary({ bot, actions }) {
// form is what the empty state's button means (#2030). A locked window draws neither the empty
// state nor its button, because that button asks for the form this window cannot draw (#2232,
// P-457); with tasks the rows still open the plain settings list, so the read half stays.
export function ScheduleSummary({ bot, roster, actions }) {
const view = useSchedules(actions)
// #2035: the shared view starts null and the first read lands a frame later, so "no view yet" is
// not "no tasks". The settings card above draws nothing for it; this line did the opposite and
// offered the new-task form. `view.available === false` (Plugins off) is a different state and
// keeps its own line below.
if (view === null || view.readFailure !== undefined) return null
const tasks = view.available ? view.tasks.filter(task => task.botId === bot.id) : []
if (lockedWindow(roster) && tasks.length === 0) return null
return h('div', null,
tasks.length === 0
? h('div', { className: 'bt-section-title' }, t('Scheduled tasks'))
Expand Down
82 changes: 82 additions & 0 deletions core/test/composer-locked.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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'

// P-457: composer.js "+" menu → "Scheduled task" reaches
// openSettings('schedules', target), which a read-only window must not offer — the settings list
// asks for the create form and a locked page draws none (#2232).

const { outputFiles } = await build({
stdin: {
contents: "export * from './src/client/composer.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 } })
const LOCK = 'Another FlyBot window has this home open; this window is read-only.'
const seeded = []
const react = {
createElement: h, Fragment: Symbol('F'), useEffect() {}, useLayoutEffect() {},
useState: v => [seeded.length > 0 ? seeded.shift() : v, () => {}],
useMemo: f => f(), useRef: current => ({ current }), useCallback: f => f,
useSyncExternalStore: (_s, get) => get(),
}
const module = { exports: {} }
runInNewContext(outputFiles[0].text, {
module, exports: module.exports, document: { hidden: false }, navigator: { language: 'en' }, localStorage: { getItem: () => null, setItem() {} },
requestAnimationFrame: run => run(),
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 { ComposerPlus } = module.exports

const all = (node, out = []) => {
if (Array.isArray(node)) { for (const item of node) all(item, out); return out }
if (typeof node === 'string') out.push(node)
else if (node && typeof node === 'object') { out.push(node); all(node.props?.children, out) }
return out
}
// The menu is open already: useState hands out the position, useLinger keeps it as `kept`.
const render = (roster) => {
seeded.length = 0
seeded.push({ left: 0, bottom: 0 }, null)
const actions = { log: [], openSettings: (...args) => actions.log.push(args) }
const element = ComposerPlus({
sessionId: 's1',
inputActions: { captureInsertion: () => 'insert' },
useRoster: select => select(roster),
actions: { ...actions, canReferenceChat: () => true, referenceChat: () => {} },
})
return { tree: element, actions }
}
const menuRows = tree => all(tree).filter(node => node?.props?.role === 'menuitem')

const WRITABLE = { bots: [], rooms: [], byId: { s1: { id: 'b1' } }, roomsById: {}, readOnly: '' }
const LOCKED = { ...WRITABLE, readOnly: LOCK }

test('writable window: the "+" menu offers Scheduled task and it opens the schedules page for the chat', () => {
const { tree, actions } = render(WRITABLE)
const rows = menuRows(tree).map(row => all(row).filter(item => typeof item === 'string').join(''))
assert.ok(rows.includes('Scheduled task'), `rows: ${JSON.stringify(rows)}`)
menuRows(tree).find(row => all(row).includes('Scheduled task')).props.onClick()
assert.deepEqual(actions.log, [['schedules', 'b1']])
})

test('read-only window: no menu row opens the schedules page', () => {
const { tree, actions } = render(LOCKED)
const rows = menuRows(tree).map(row => all(row).filter(item => typeof item === 'string').join(''))
assert.ok(!rows.includes('Scheduled task'), `a read-only window still offers the row: ${JSON.stringify(rows)}`)
const scheduleRows = menuRows(tree).filter(row => all(row).includes('Scheduled task'))
for (const row of scheduleRows) row.props.onClick()
assert.deepEqual(actions.log, [])
assert.deepEqual(scheduleRows.map(row => all(row).filter(item => typeof item === 'string').join('')), [])
})
32 changes: 32 additions & 0 deletions core/test/schedules-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -781,3 +781,35 @@ test('#2232: Quiet hours and the form announce a refusal, not only print it', as
assert.equal(formAlerts.length, 1, 'one alert on the form')
assert.ok(text(formAlerts[0]).join(' ').includes(sentence))
})

// P-457: #2235 hid every scheduled-task write a read-only window draws, but two entry points to
// the create form stayed: a Bot's card kept its New task row and the details line kept its empty
// state's Add one, and both asked the settings list for that Bot's id — which opens the form, and
// a locked page draws none (the page itself stays form-free, the third test here).
test('P-457: a read-only Bot card draws no New task row', () => {
seeded.push({ available: true, tasks: [] })
const card = render(BotSchedules({ bot: { id: 'b1' }, roster: LOCKED_ROSTER, actions: scheduleActions() }))
const labels = buttonsOf(card).map(labelOf)
assert.ok(!labels.includes('New task'), `a read-only Bot card still offers New task, saw ${labels.join('|')}`)
// The row is a create entry, not the lock: the same card in a writable window keeps it.
seeded.push({ available: true, tasks: [] })
const writable = render(BotSchedules({ bot: { id: 'b1' }, roster: ROSTER, actions: scheduleActions() }))
assert.ok(buttonsOf(writable).map(labelOf).includes('New task'), 'a writable window keeps the New task row')
})

test('P-457: a locked page opened with a start id draws no create form', () => {
seeded.push({ available: true, tasks: [ACTIVE] })
const page = render(SchedulesPage({ roster: LOCKED_ROSTER, actions: scheduleActions(), start: 'b1' }))
assert.deepEqual(fieldControls(page).map(node => node.type), [], 'a read-only window draws no create form')
assert.ok(!buttonsOf(page).map(labelOf).includes('New task'), 'and no New task row')
})

test('P-457: a read-only details line offers no Add one, and a writable one does', () => {
const actions = opened()
seeded.push({ available: true, tasks: [] })
const locked = render(ScheduleSummary({ bot: { id: 'b1' }, roster: LOCKED_ROSTER, actions }))
assert.deepEqual(buttonsOf(locked).map(labelOf), [], 'the locked details line draws no button')
seeded.push({ available: true, tasks: [] })
const writable = render(ScheduleSummary({ bot: { id: 'b1' }, roster: ROSTER, actions }))
assert.deepEqual(buttonsOf(writable).map(labelOf), ['No scheduled tasks yet. Add one'], 'a writable details line keeps its Add one')
})
Loading
Loading