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
11 changes: 6 additions & 5 deletions .github/scripts/ds-bot-patches.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,12 @@ if (gitAvailable) {
const root = join(import.meta.dirname, '..', '..')
const patchesDir = join(root, 'patches', 'ds-bot')
const stored = readdirSync(patchesDir).filter(name => /^\d{4}-.*\.patch$/.test(name)).sort()
// 11: `0017-task-pool-role-queues.patch` only edited whole-file mirrors
// (`src/host/tasks.js`, `src/host/hooks.js`) plus one `tools.js` line that lives in `0002`,
// so the generator folds it away instead of storing a patch that applies nothing. The count is
// 11 since `0018-project-dossier.patch`, which edits upstream files and so is stored.
assert.equal(stored.length, 11)
// 10: `0017-task-pool-role-queues.patch` only edited whole-file mirrors
// (`src/host/tasks.js`, `src/host/hooks.js`) plus one `tools.js` line that lives in `0002`, so
// the generator folds it away instead of storing a patch that applies nothing. `0018` (#1883)
// goes the same way once the generator runs: its `client.js` hunk moves into `0008` and its
// other edits into their own topics, so a regenerated series stores 10 patches.
assert.equal(stored.length, 10)
const viaGit = mkdtempSync(join(tmpdir(), 'patch-git-'))
const viaOurs = mkdtempSync(join(tmpdir(), 'patch-ours-'))
try {
Expand Down
1 change: 1 addition & 0 deletions changelog.d/1887-memory-counter.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Project settings memory box warns about its bound before the click: a `N/600` counter under the field counts code points like the host, a 601st code point turns the counter and the Save button into their over-limit state, and an empty or whitespace-only box disables Save, so neither state calls `project-memory-write` or writes anything ([#1887](https://github.com/ThinkFlowLab/FlyBot/issues/1887)).
23 changes: 22 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -10464,6 +10464,10 @@ var PROJECT_BOARD_CSS = `
.bt-project-field label{font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
.bt-project-field input,.bt-project-field select,.bt-project-field textarea{font:inherit;padding:6px 8px;border-radius:8px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:var(--bt-card,rgba(127,127,127,.08));color:inherit}
.bt-project-field textarea{min-height:88px;resize:vertical}
/* #1887: the counter under the memory box. It stays quiet until the text passes the bound, and
past it turns the one colour that says so; the disabled Save is the state a reader acts on. */
.bt-project-count{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}
.bt-project-count[data-over="true"]{opacity:1;color:#c2410c}
.bt-project-row-actions{display:flex;gap:8px;align-items:center}
.bt-project-members{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
.bt-project-member{display:flex;gap:10px;align-items:baseline;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08))}
Expand Down Expand Up @@ -10502,6 +10506,8 @@ var PROJECT_BOARD_CSS = `
var PROJECT_SOURCES = ["bot", "group", "workflow"];
var SOURCE_LABELS = { bot: "Bots", group: "Groups", workflow: "Workflows" };
var text = (value) => typeof value === "string" ? value.trim() : "";
var MEMORY_TEXT_MAX = 600;
var memoryLength = (value) => [...text(value)].length;
var DONE_COLUMN = "done";
var boardOfRow = (row, task) => {
if (task === void 0) return null;
Expand Down Expand Up @@ -11132,6 +11138,9 @@ function ProjectSettingsPanel({ project, assistantMine, call: request, onClose,
const [kind, setKind] = (0, import_react19.useState)("bot");
const [memberId, setMemberId] = (0, import_react19.useState)("");
const [note, setNote] = (0, import_react19.useState)("");
const noteLength = memoryLength(note);
const noteOver = noteLength > MEMORY_TEXT_MAX;
const noteSaveDisabled = text(note) === "" || noteOver;
const [assistantOff, setAssistantOff] = (0, import_react19.useState)(assistantMine === void 0 ? project?.assistant?.off === true : assistantMine?.off === true);
const [status, setStatus] = (0, import_react19.useState)("");
const id = text(project?.id);
Expand Down Expand Up @@ -11186,10 +11195,22 @@ function ProjectSettingsPanel({ project, assistantMine, call: request, onClose,
// never as a third kind the host would refuse (#1820).
(0, import_react19.createElement)("label", { htmlFor: "bt-project-note" }, t("Add to the assistant memory")),
(0, import_react19.createElement)("textarea", { id: "bt-project-note", value: note, onChange: (event) => setNote(event.target.value) }),
(0, import_react19.createElement)("div", { className: "bt-project-count", "data-role": "project-memory-count", "data-over": noteOver ? "true" : "false" }, `${noteLength}/${MEMORY_TEXT_MAX}`),
(0, import_react19.createElement)(
"div",
{ className: "bt-project-row-actions" },
(0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-memory-save", onClick: () => run("project-memory-write", { id, kind: "decision", text: text(note) }, t("Memory saved.")) }, t("Save memory"))
(0, import_react19.createElement)("button", {
type: "button",
className: "bt-soft bt-soft-sm",
"data-action": "project-memory-save",
disabled: noteSaveDisabled,
// A disabled button draws no click, but the guard is what a keyboard or a stale tree
// could still reach: neither state sends a request or writes anything.
onClick: () => {
if (noteSaveDisabled) return;
run("project-memory-write", { id, kind: "decision", text: text(note) }, t("Memory saved."));
}
}, t("Save memory"))
)
),
(0, import_react19.createElement)("div", { className: "bt-project-status-line", role: "status" }, status),
Expand Down
31 changes: 30 additions & 1 deletion core/src/client/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ export const PROJECT_BOARD_CSS = `
.bt-project-field label{font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
.bt-project-field input,.bt-project-field select,.bt-project-field textarea{font:inherit;padding:6px 8px;border-radius:8px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:var(--bt-card,rgba(127,127,127,.08));color:inherit}
.bt-project-field textarea{min-height:88px;resize:vertical}
/* #1887: the counter under the memory box. It stays quiet until the text passes the bound, and
past it turns the one colour that says so; the disabled Save is the state a reader acts on. */
.bt-project-count{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}
.bt-project-count[data-over="true"]{opacity:1;color:#c2410c}
.bt-project-row-actions{display:flex;gap:8px;align-items:center}
.bt-project-members{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
.bt-project-member{display:flex;gap:10px;align-items:baseline;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08))}
Expand Down Expand Up @@ -124,6 +128,14 @@ export const SOURCE_LABELS = { bot: 'Bots', group: 'Groups', workflow: 'Workflow

const text = value => (typeof value === 'string' ? value.trim() : '')

// #1887: the memory box warns about the bound before the click, the way the host bounds it. One
// entry is at most `MAX_ENTRY` (`src/host/constants.js`, #1847) and the host counts its code points
// (#1886), so the counter counts the same unit on the trimmed text the host would store — which is
// also why the field carries no `maxLength`: that counts UTF-16 units and would call a 600-emoji
// entry twice its real length.
export const MEMORY_TEXT_MAX = 600
const memoryLength = (value) => [...text(value)].length

// #1855: the column a finished card reads. The host sends those cards apart (`done`) and the view
// keeps them out of every group of work in flight.
const DONE_COLUMN = 'done'
Expand Down Expand Up @@ -753,6 +765,11 @@ export function ProjectSettingsPanel({ project, assistantMine, call: request, on
const [kind, setKind] = useState('bot')
const [memberId, setMemberId] = useState('')
const [note, setNote] = useState('')
// #1887: an empty box has nothing to save and an over-long one can never land, so neither
// reaches the host — the reader sees the state before the click instead of a refusal after it.
const noteLength = memoryLength(note)
const noteOver = noteLength > MEMORY_TEXT_MAX
const noteSaveDisabled = text(note) === '' || noteOver
// #1845: the digest opt-out is per owner, and the host keeps it on `owners[<ownerId>]`; the
// Project record has no such field, so reading it there showed a paused digest as unticked and
// the next Save turned it back on. `project-read` answers the caller's own record beside the
Expand Down Expand Up @@ -794,8 +811,20 @@ export function ProjectSettingsPanel({ project, assistantMine, call: request, on
// never as a third kind the host would refuse (#1820).
h('label', { htmlFor: 'bt-project-note' }, t('Add to the assistant memory')),
h('textarea', { id: 'bt-project-note', value: note, onChange: event => setNote(event.target.value) }),
h('div', { className: 'bt-project-count', 'data-role': 'project-memory-count', 'data-over': noteOver ? 'true' : 'false' }, `${noteLength}/${MEMORY_TEXT_MAX}`),
h('div', { className: 'bt-project-row-actions' },
h('button', { type: 'button', className: 'bt-soft bt-soft-sm', 'data-action': 'project-memory-save', onClick: () => run('project-memory-write', { id, kind: 'decision', text: text(note) }, t('Memory saved.')) }, t('Save memory')))),
h('button', {
type: 'button',
className: 'bt-soft bt-soft-sm',
'data-action': 'project-memory-save',
disabled: noteSaveDisabled,
// A disabled button draws no click, but the guard is what a keyboard or a stale tree
// could still reach: neither state sends a request or writes anything.
onClick: () => {
if (noteSaveDisabled) return
run('project-memory-write', { id, kind: 'decision', text: text(note) }, t('Memory saved.'))
},
}, t('Save memory')))),
h('div', { className: 'bt-project-status-line', role: 'status' }, status),
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
96 changes: 72 additions & 24 deletions core/test/project-board-panels.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,9 @@ test('a memory kind the host does not know writes nothing, and the panel quotes
try {
const refused = async () => { throw Object.assign(new Error('Only the owner of this Project may change it'), { code: 'bot/forbidden' }) }
const draw = () => render(ProjectSettingsPanel, { project: project(), call: refused, onClose() {} })
// #1887: an empty box no longer reaches the host, so the entry is one the panel allows; the
// refusal below is the owner gate, which is what this half of the test is about.
all(draw()).find(node => node.props?.id === 'bt-project-note').props.onChange({ target: { value: 'ship on Friday' } })
let tree = draw()
byAction(tree, 'project-memory-save').props.onClick()
assert.equal(await statusAfter(draw, '只有这个项目的主人可以修改'), '只有这个项目的主人可以修改')
Expand Down Expand Up @@ -345,36 +348,29 @@ test('#1847: the memory write door refuses empty and over-long text and stores 6
assert.equal(memory.decisions[0].text, exact, 'the text is what was sent')
})

// The other half of #1847: the panel quotes the host's new refusals in Chinese, so the reader sees
// why Save did nothing instead of a success line, and no row is added.
test('#1847: the panel shows the Chinese refusal for an empty or over-long memory box', async (t) => {
// The other half of #1847: the panel quotes the host's refusals in Chinese. Since #1887 the panel
// refuses an empty or over-long box before the click, so the one refusal still reachable is the two
// bounds disagreeing — an emoji entry the client counts as exactly 600 code points while a host
// that still counted UTF-16 units (#1886) answers "at most 600 characters". The reader must see
// why Save did nothing, never a success line, and no row may land.
test('#1847: the panel shows the Chinese refusal when the host refuses the memory text', async (t) => {
resetSlots()
const rt = await startHost(t)
const call = (endpoint, payload) => rt.projectEndpoint(endpoint, payload)
const off = installLocale(fakeLocale('zh'))
try {
// The hook slots outlive a render, so the box starts where the previous test left it; empty it
// explicitly before pressing Save.
let tree = render(ProjectSettingsPanel, { project: project(), call, onClose() {} })
all(tree).find(node => node.props?.id === 'bt-project-note').props.onChange({ target: { value: '' } })
tree = render(ProjectSettingsPanel, { project: project(), call, onClose() {} })
byAction(tree, 'project-memory-save').props.onClick()
await settle()
tree = render(ProjectSettingsPanel, { project: project(), call, onClose() {} })
let status = all(tree).find(node => node.props?.role === 'status')
assert.equal(text(status), '项目记忆条目需要文字')
assert.ok(!text(status).includes('Memory saved.'), 'a refusal is never painted as a success')

const note = all(tree).find(node => node.props?.id === 'bt-project-note')
note.props.onChange({ target: { value: 'x'.repeat(601) } })
tree = render(ProjectSettingsPanel, { project: project(), call, onClose() {} })
byAction(tree, 'project-memory-save').props.onClick()
await settle()
tree = render(ProjectSettingsPanel, { project: project(), call, onClose() {} })
status = all(tree).find(node => node.props?.role === 'status')
assert.equal(text(status), '项目记忆条目最多 600 个字符')
const draw = () => render(ProjectSettingsPanel, { project: project(), call, onClose() {} })
all(draw()).find(node => node.props?.id === 'bt-project-note').props.onChange({ target: { value: '🙂'.repeat(600) } })
const refused = Object.assign(new Error('A Project memory entry is at most 600 characters'), { code: 'bot/invalid' })
const racing = () => render(ProjectSettingsPanel, { project: project(), call: async () => { throw refused }, onClose() {} })
assert.ok(!byAction(racing(), 'project-memory-save').props.disabled, 'the client counts the entry at the bound and lets the click through')
byAction(racing(), 'project-memory-save').props.onClick()
assert.equal(await statusAfter(racing, '项目记忆条目最多 600 个字符'), '项目记忆条目最多 600 个字符')
assert.ok(!drawnStatus(racing).includes('记忆已保存。'), 'a refusal is never painted as a success')
assert.equal(hostText('A Project memory entry needs text'), '项目记忆条目需要文字', 'the empty-text refusal is translated too, for a host that refuses it')

const memory = (await call('project-memory', { id: 'project-1' })).memory
assert.deepEqual(memory.decisions, [], 'neither refusal adds a row')
assert.deepEqual(memory.decisions, [], 'the refusal adds no row')
} finally {
off()
}
Expand All @@ -385,6 +381,8 @@ test("the settings panel shows the host's refusal instead of pretending the save
const refused = Object.assign(new Error('Only the owner of this Project may change it'), { code: 'bot/forbidden' })
const save = async () => { throw refused }
const draw = () => render(ProjectSettingsPanel, { project: { id: 'project-1' }, call: save, onClose() {} })
// #1887: the box needs an entry the panel allows, or Save is disabled and no host refusal is asked.
all(draw()).find(node => node.props?.id === 'bt-project-note').props.onChange({ target: { value: 'ship on Friday' } })
const tree = draw()
byAction(tree, 'project-memory-save').props.onClick()
await waitFor(() => (/Only the owner of this Project/.test(drawnStatus(draw)) ? true : undefined))
Expand Down Expand Up @@ -536,3 +534,53 @@ test('the owner\'s chat entry opens the Project assistant, and only the owner\'s
assert.equal(projectAgentFor({ ownerId: 'owner-1' }, 'owner-1'), null, 'a Bot outside a Project offers no entry')
assert.equal(projectAgentFor({ projectId: 'project-1', ownerId: 'owner-1', remote: {} }, 'owner-1'), null, "a friend's Bot offers no entry")
})

// #1887: the memory box warns before the click. The field had no counter and Save stayed enabled
// with nothing typed, so the first news of the 600 bound was the host's refusal after the click
// (#1847 already refuses at the door — the defect is the silence before it, not a blank entry
// landing). The counter counts code points, the unit the host bounds (#1886), so an emoji entry at
// the bound is not refused for what `maxLength` would have counted as twice as many UTF-16 units.
const memoryCount = (tree) => all(tree).find(node => node.props?.['data-role'] === 'project-memory-count')
const memorySave = (tree) => byAction(tree, 'project-memory-save')

test('#1887: the memory box counts code points and disables Save before the click', async (t) => {
resetSlots()
const rt = await startHost(t)
let calls = 0
const call = (endpoint, payload) => { calls += 1; return rt.projectEndpoint(endpoint, payload) }
const draw = () => render(ProjectSettingsPanel, { project: project(), call, onClose() {} })
const type = (value) => {
all(draw()).find(node => node.props?.id === 'bt-project-note').props.onChange({ target: { value } })
return draw()
}

// Empty: there is nothing to save, and the reader is told before pressing anything.
let tree = type('')
assert.equal(memorySave(tree).props.disabled, true, 'an empty memory box disables Save')
assert.equal(text(memoryCount(tree)), '0/600', 'the counter reads the empty box')
memorySave(tree).props.onClick()
await settle()

// Whitespace is not an entry either: the host refuses it as no text, so the button agrees.
tree = type(' \n ')
assert.equal(memorySave(tree).props.disabled, true, 'a whitespace-only memory box disables Save')

// One past the bound: the counter says so and Save is disabled before the click.
const emoji = '🙂'
tree = type(emoji.repeat(601))
assert.equal(text(memoryCount(tree)), '601/600', 'the counter counts code points, not UTF-16 units')
assert.equal(memoryCount(tree).props['data-over'], 'true', 'the counter is in its over-limit state')
assert.equal(memorySave(tree).props.disabled, true, 'an over-long entry disables Save')
memorySave(tree).props.onClick()
await settle()

// The bound itself is an entry: Save is enabled and the counter reads it whole.
tree = type(emoji.repeat(600))
assert.equal(text(memoryCount(tree)), '600/600', 'an entry at the bound reads the bound')
assert.equal(memoryCount(tree).props['data-over'], 'false', 'and the counter is not in its over-limit state')
assert.ok(!memorySave(tree).props.disabled, 'the bound itself is allowed')

assert.equal(calls, 0, 'neither a disabled nor an over-limit Save calls the endpoint')
const memory = (await rt.projectEndpoint('project-memory', { id: 'project-1' })).memory
assert.deepEqual(memory.decisions, [], 'and neither state writes anything')
})
Loading
Loading