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/2247-readonly-banner-reload.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A read-only FlyBot window's sidebar banner now offers **Reload** in both the wide and the rail shape: it re-reads the whole team instead of reloading the page, so the banner and every refused control clear as soon as the other window has let go, and an unsent composer line is not lost. ([#2247](https://github.com/ThinkFlowLab/FlyBot/issues/2247)).
36 changes: 33 additions & 3 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2817,6 +2817,11 @@ function createSource(initial) {
}
var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], humans: [], self: null, agents: [], exchangeTurns: {}, remoteTypedTurns: {}, remoteAwaiting: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false };
var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id);
var stateRequest = (current, { fresh = false } = {}) => ({
since: fresh ? void 0 : current?.revision,
models: current?.modelsKey ?? ""
});
var stateAnswerIsStale = (value, current, { fresh = false } = {}) => value?.unchanged === true || !fresh && value?.revision === current?.revision && value?.modelsKey === current?.modelsKey;
function indexRoster(value) {
setLocalBots(value.bots);
setPluginTools(value.pluginTools);
Expand Down Expand Up @@ -10567,6 +10572,13 @@ function TeamSidebar(props) {
}, [roster, updated]);
const main = roster.byId[roster.mainBotId];
const readOnlySaid = typeof roster.readOnly === "string" && roster.readOnly.trim() !== "";
const readOnlyReload = (0, import_react18.createElement)("button", {
type: "button",
className: "bt-read-only-reload",
onClick: () => {
void Promise.resolve(actions.reloadTeam?.()).catch((error) => actions.reportError(error, "reload"));
}
}, t("Reload"));
const structure = useSidebarStructure();
const [collapsed, setCollapsed] = (0, import_react18.useState)({});
const toggle = (id) => setCollapsed((value) => ({ ...value, [id]: !value[id] }));
Expand Down Expand Up @@ -10599,7 +10611,9 @@ function TeamSidebar(props) {
(0, import_react18.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react18.createElement)(PlusIcon))
),
wide && readOnlySaid ? (0, import_react18.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null,
wide && readOnlySaid ? readOnlyReload : null,
!wide && readOnlySaid ? (0, import_react18.createElement)("div", { className: "bt-hint bt-read-only", role: "status", title: hostText(roster.readOnly), "aria-label": hostText(roster.readOnly) }, hostText(roster.readOnly)) : null,
!wide && readOnlySaid ? readOnlyReload : null,
(0, import_react18.createElement)(InboxRow, { wide, useRoster, useWork: props.useWork, useUi, actions }),
(0, import_react18.createElement)(KanbanRow, { wide, useUi, actions }),
(0, import_react18.createElement)(ProjectsNav, { wide }),
Expand Down Expand Up @@ -10961,6 +10975,9 @@ var SIDEBAR_CSS = `
.bt-rail .bt-pin{position:absolute;top:3px;left:calc(50% + 12px)}
.bt-side .bt-read-only{margin:4px 8px 6px;padding:8px 10px;border-radius:10px;background:var(--bt-hover);font-size:12px;line-height:1.45;text-align:left}
.bt-rail .bt-read-only{width:100%;margin:4px 0 6px;padding:4px 2px;font-size:10px;line-height:1.3;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bt-read-only-reload{margin:0 8px 8px;padding:3px 10px;border:1px solid var(--bt-line);border-radius:8px;background:none;color:inherit;font:inherit;font-size:12px;line-height:18px;cursor:pointer;transition:background-color .12s ease}
.bt-read-only-reload:hover{background:var(--bt-hover)}
.bt-rail .bt-read-only-reload{width:100%;margin:0 0 6px;padding:2px 0;font-size:10px;line-height:16px;text-align:center}
[class*="_footArea"]:has(.bt-foot-account){display:flex!important;flex-direction:row!important;align-items:center;gap:6px}
[class*="_footArea"]:has(.bt-foot-account)>[class*="_footerActions"],[class*="_footArea"]:has(.bt-foot-account)>[class*="_footerActions"]>div{display:contents!important}
.bt-foot-account{order:-1;flex:none;display:inline-flex}
Expand Down Expand Up @@ -22172,10 +22189,11 @@ function apply(ctx) {
const refreshed = /* @__PURE__ */ new Set();
let pulls = 0;
let applied = 0;
const pull = async () => {
let reloading = 0;
const pull = async ({ fresh = false } = {}) => {
const order = ++pulls;
const known = roster.getSnapshot();
const value = await call("state", { since: known.revision, models: known.modelsKey ?? "", locale: language(), watching: !document.hidden });
const value = await call("state", { ...stateRequest(known, { fresh }), locale: language(), watching: !document.hidden });
if (order < applied) return;
applied = order;
const nextActivity = JSON.stringify(value.activity ?? {});
Expand Down Expand Up @@ -22203,7 +22221,7 @@ function apply(ctx) {
live2.pluginTools = value.pluginTools;
}
if (Object.keys(live2).length > 0 && current.ready) roster.set({ ...current, ...live2 });
if (value.unchanged || value.revision === current.revision && value.modelsKey === current.modelsKey) return;
if (stateAnswerIsStale(value, current, { fresh })) return;
if (Object.keys(seenAt).length === 0) leaveVisits([...value.bots, ...value.rooms].map((entry) => entry.id));
roster.set(indexRoster(value));
noteFriendLines(value);
Expand All @@ -22230,6 +22248,10 @@ function apply(ctx) {
const loop = async () => {
clearTimeout(timer);
if (running || stopped) return;
if (reloading > 0) {
timer = setTimeout(loop, 200);
return;
}
running = true;
try {
await pull();
Expand Down Expand Up @@ -22494,6 +22516,14 @@ function apply(ctx) {
selectPanel: (id) => {
ctx.layout.selectPanel(id);
},
// The read-only banner's Reload: read the whole team again, so a window whose other holder let
// go becomes writable without a page reload that would drop what is in the composer (#2247).
reloadTeam: () => {
reloading += 1;
return pull({ fresh: true }).finally(() => {
reloading -= 1;
});
},
openNewChat: (mode) => {
ui2.set((state) => ({ ...state, newChat: mode, menu: null, palette: null }));
},
Expand Down
18 changes: 14 additions & 4 deletions core/src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { TURN_FAILURES_CSS, TurnFailures } from './turn-failures.js'
import { call } from './api.js'
import { isHex } from './inks.js'
import { SHAPES, markupCache } from './characters.js'
import { countCards, createSource, EMPTY_ROSTER, indexRoster, isAgentSession } from './sources.js'
import { countCards, createSource, EMPTY_ROSTER, indexRoster, isAgentSession, stateAnswerIsStale, stateRequest } from './sources.js'
import { themeCss, shellTokens, registry, registerShape, registerTheme, allThemes } from './themes.js'
import { currentPart, landingPart, setPluginTools } from './text.js'
import { mentionCss } from './mention-style.js'
Expand Down Expand Up @@ -172,11 +172,14 @@ export function apply(ctx) {
// Pulls can overlap (the poll and a navigation); an older answer never wins.
let pulls = 0
let applied = 0
const pull = async () => {
// A Reload in flight makes the poll stand back. Otherwise the loop's `unchanged` answer can take
// the applied slot first and the full answer the press asked for is dropped as the older one.
let reloading = 0
const pull = async ({ fresh = false } = {}) => {
const order = ++pulls
const known = roster.getSnapshot()
// `watching`: this window is on screen, so the host reads friends' inboxes at the live cadence (#497).
const value = await call('state', { since: known.revision, models: known.modelsKey ?? '', locale: language(), watching: !document.hidden })
const value = await call('state', { ...stateRequest(known, { fresh }), locale: language(), watching: !document.hidden })
if (order < applied) return
applied = order
const nextActivity = JSON.stringify(value.activity ?? {})
Expand All @@ -194,7 +197,7 @@ export function apply(ctx) {
// Plugin tool names come with a plugin reload, not a revision; context rows re-read them (#419).
if (value.pluginTools !== undefined && JSON.stringify(value.pluginTools) !== JSON.stringify(current.pluginTools ?? [])) { setPluginTools(value.pluginTools); live.pluginTools = value.pluginTools }
if (Object.keys(live).length > 0 && current.ready) roster.set({ ...current, ...live })
if (value.unchanged || (value.revision === current.revision && value.modelsKey === current.modelsKey)) return
if (stateAnswerIsStale(value, current, { fresh })) return
// On a browser with no visit record yet, everything already there counts as seen.
if (Object.keys(seenAt).length === 0) leaveVisits([...value.bots, ...value.rooms].map(entry => entry.id))
roster.set(indexRoster(value))
Expand Down Expand Up @@ -223,6 +226,7 @@ export function apply(ctx) {
const loop = async () => {
clearTimeout(timer)
if (running || stopped) return
if (reloading > 0) { timer = setTimeout(loop, 200); return }
running = true
try { await pull() } catch (error) { if (!stopped) console.warn('[ds-bot] state', error) } finally { running = false }
if (!stopped) timer = setTimeout(loop, document.hidden ? 10000 : 1500)
Expand Down Expand Up @@ -461,6 +465,12 @@ export function apply(ctx) {
ctx.uiWorkspace.openSession(target)
},
selectPanel: (id) => { ctx.layout.selectPanel(id) },
// The read-only banner's Reload: read the whole team again, so a window whose other holder let
// go becomes writable without a page reload that would drop what is in the composer (#2247).
reloadTeam: () => {
reloading += 1
return pull({ fresh: true }).finally(() => { reloading -= 1 })
},
openNewChat: (mode) => { ui.set(state => ({ ...state, newChat: mode, menu: null, palette: null })) },
openPalette: (query = '') => { ui.set(state => ({ ...state, palette: { query, token: Date.now() }, newChat: null, menu: null })) },
// The token keeps a late exit timer from closing a palette opened again since.
Expand Down
13 changes: 13 additions & 0 deletions core/src/client/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,14 @@ export function TeamSidebar(props) {
// Read the same way as the workflow page (#2176), so the two surfaces cannot disagree; the text
// drawn is still the host's own sentence, not a trimmed copy.
const readOnlySaid = typeof roster.readOnly === 'string' && roster.readOnly.trim() !== ''
// The host's sentence tells the person to close the other window and reload; this is that
// entry. It reads the team again instead of reloading the page, so the composer is kept, and
// it sits beside the status line rather than inside it so the live region stays text (#2247).
const readOnlyReload = h('button', {
type: 'button',
className: 'bt-read-only-reload',
onClick: () => { void Promise.resolve(actions.reloadTeam?.()).catch(error => actions.reportError(error, 'reload')) },
}, t('Reload'))
// A plugin (the tree) may give the Bots a structure: the list then is one tree under the Main Bot.
const structure = useSidebarStructure()
const [collapsed, setCollapsed] = useState({})
Expand Down Expand Up @@ -398,7 +406,9 @@ export function TeamSidebar(props) {
h(UpdateButton, { useUpdate: props.useUpdate, actions }),
h('button', { type: 'button', className: 'bt-round', 'aria-label': t('New…'), title: t('New…'), onClick: () => actions.openNewChat('new') }, h(PlusIcon))),
wide && readOnlySaid ? h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) : null,
wide && readOnlySaid ? readOnlyReload : null,
!wide && readOnlySaid ? h('div', { className: 'bt-hint bt-read-only', role: 'status', title: hostText(roster.readOnly), 'aria-label': hostText(roster.readOnly) }, hostText(roster.readOnly)) : null,
!wide && readOnlySaid ? readOnlyReload : null,
h(InboxRow, { wide, useRoster, useWork: props.useWork, useUi, actions }),
h(KanbanRow, { wide, useUi, actions }),
h(ProjectsNav, { wide }),
Expand Down Expand Up @@ -740,6 +750,9 @@ export const SIDEBAR_CSS = `
.bt-rail .bt-pin{position:absolute;top:3px;left:calc(50% + 12px)}
.bt-side .bt-read-only{margin:4px 8px 6px;padding:8px 10px;border-radius:10px;background:var(--bt-hover);font-size:12px;line-height:1.45;text-align:left}
.bt-rail .bt-read-only{width:100%;margin:4px 0 6px;padding:4px 2px;font-size:10px;line-height:1.3;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bt-read-only-reload{margin:0 8px 8px;padding:3px 10px;border:1px solid var(--bt-line);border-radius:8px;background:none;color:inherit;font:inherit;font-size:12px;line-height:18px;cursor:pointer;transition:background-color .12s ease}
.bt-read-only-reload:hover{background:var(--bt-hover)}
.bt-rail .bt-read-only-reload{width:100%;margin:0 0 6px;padding:2px 0;font-size:10px;line-height:16px;text-align:center}
[class*="_footArea"]:has(.bt-foot-account){display:flex!important;flex-direction:row!important;align-items:center;gap:6px}
[class*="_footArea"]:has(.bt-foot-account)>[class*="_footerActions"],[class*="_footArea"]:has(.bt-foot-account)>[class*="_footerActions"]>div{display:contents!important}
.bt-foot-account{order:-1;flex:none;display:inline-flex}
Expand Down
13 changes: 13 additions & 0 deletions core/src/client/sources.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ export const EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bot
// Any Main Bot holds the Main Bot powers; `mainBotId`, the first one, is the sidebar tile.
export const isMainOf = (roster, id) => id !== undefined && (roster.mainBotIds ?? []).includes(id)

// The `state` request the poll sends. `since` asks for a revision delta; a person who pressed
// Reload asks for the whole team, so the answer carries `readOnly` re-read by the host (#2247).
export const stateRequest = (current, { fresh = false } = {}) => ({
since: fresh ? undefined : current?.revision,
models: current?.modelsKey ?? '',
})

// Whether a `state` answer may be skipped. An `unchanged` answer carries no team, so it is never
// indexed; the poll also skips the same revision and models key. A reload draws that same revision:
// that is how a stale read-only sentence clears without a page reload (#2247).
export const stateAnswerIsStale = (value, current, { fresh = false } = {}) =>
value?.unchanged === true || (!fresh && value?.revision === current?.revision && value?.modelsKey === current?.modelsKey)

// A Bot's chat is a chain of Sessions ("parts"): the Bot id is its first part's id
// and `sessionId` its current one. Every part id finds the Bot; Session state
// (running, unread, turns) is read from the current part, team state from the Bot id.
Expand Down
55 changes: 55 additions & 0 deletions core/test/client-reload-state.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// #2247: a read-only window told the person to reload, and there was no reload entry. The
// sidebar's new Reload button asks for the team again. Two rules make that read useful, and
// they belong together: the request must carry no `since` (otherwise the host answers
// `unchanged`, with no team at all), and the answer must be drawn even though the revision did
// not move (otherwise the stale read-only sentence stays). Neither rule alone is enough: a
// skipped answer still drawn would be an empty roster, and a drawn answer still skipped is
// today's behaviour.
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 { stateRequest, stateAnswerIsStale } from './src/client/sources.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 module = { exports: {} }
runInNewContext(outputFiles[0].text, {
module, exports: module.exports, console, Date, JSON, Promise, Symbol,
require: (name) => {
if (name === 'react') return { createElement: () => ({}) }
throw new Error(`Unexpected platform module ${name}`)
},
})
const { stateRequest, stateAnswerIsStale } = module.exports

test('the poll asks for a revision delta; a reload asks for the whole team', () => {
const known = { revision: 7, modelsKey: 'models-1' }
const polled = stateRequest(known)
assert.equal(polled.since, 7)
assert.equal(polled.models, 'models-1')
const fresh = stateRequest(known, { fresh: true })
assert.equal(fresh.since, undefined, 'no `since`, or the host answers `unchanged` and sends no team')
assert.equal(fresh.models, 'models-1')
// An empty roster before the first pull sends no number either.
assert.equal(stateRequest({ revision: undefined, modelsKey: undefined }).since, undefined)
})

test('a reload draws the same revision; the poll still skips it', () => {
const current = { revision: 7, modelsKey: 'models-1', readOnly: 'open in another dsh' }
const answer = { revision: 7, modelsKey: 'models-1', readOnly: null, bots: [] }
assert.equal(stateAnswerIsStale(answer, current), true, 'the poll skips an unchanged revision')
assert.equal(stateAnswerIsStale(answer, current, { fresh: true }), false, 'a reload draws it, which is how the sentence can clear')
// An `unchanged` answer carries no team, so it is never indexed, reload or not.
assert.equal(stateAnswerIsStale({ revision: 7, unchanged: true }, current, { fresh: true }), true)
// A new revision is always drawn.
assert.equal(stateAnswerIsStale({ revision: 8, modelsKey: 'models-1' }, current), false)
// A new models key is drawn even at the same revision.
assert.equal(stateAnswerIsStale({ revision: 7, modelsKey: 'models-2' }, current), false)
})
Loading
Loading