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/2833-feedback-unread-log.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Feedback page now says when the error log could not be read instead of claiming nothing was recorded: when the host reports the log unreadable, the empty state names the reason (for example `EACCES`) and says the errors it holds are not shown, while any errors the report does carry are drawn unchanged. See [#2833](https://github.com/ThinkFlowLab/FlyBot/issues/2833).
7 changes: 6 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1260,6 +1260,7 @@ var ZH = {
"Recent errors": "最近的错误",
"Recent errors · {count}": "最近的错误 · {count}",
"No errors recorded.": "没有错误记录。",
"The error log could not be read ({reason}); the errors it holds are not shown.": "错误日志读不出来({reason});里面的错误没有显示。",
"Failed model calls and FlyBot problems show here, newest first.": "模型调用失败和 FlyBot 的问题会显示在这里,最新的在前。",
"Model call": "模型调用",
"This window": "这个窗口",
Expand Down Expand Up @@ -19069,6 +19070,7 @@ function FeedbackPage({ roster, actions, run, Section, Row: Row4 }) {
const browserErrors = report ? clientErrors().map((entry) => ({ ...entry, source: "browser" })) : [];
const entries2 = [...report?.entries ?? [], ...browserErrors].sort((a, b) => new Date(b.time) - new Date(a.time));
const total = (report?.total ?? 0) + browserErrors.length;
const unread = typeof report?.unread === "string" ? report.unread : void 0;
const shown = allErrors ? entries2 : entries2.slice(0, 8);
return [
(0, import_react40.createElement)(
Expand Down Expand Up @@ -19107,7 +19109,10 @@ function FeedbackPage({ roster, actions, run, Section, Row: Row4 }) {
(0, import_react40.createElement)(
Section,
{ key: "errors", title: report ? t("Recent errors · {count}", { count: total }) : t("Recent errors") },
shown.length === 0 ? (0, import_react40.createElement)(Row4, { label: t("No errors recorded."), hint: t("Failed model calls and FlyBot problems show here, newest first.") }) : shown.map((entry, index) => (0, import_react40.createElement)(
shown.length === 0 ? (0, import_react40.createElement)(Row4, {
label: unread === void 0 ? t("No errors recorded.") : t("The error log could not be read ({reason}); the errors it holds are not shown.", { reason: unread }),
hint: t("Failed model calls and FlyBot problems show here, newest first.")
}) : shown.map((entry, index) => (0, import_react40.createElement)(
"div",
{ key: `${entry.time}-${index}`, className: "bt-set-row bt-fb-entry", title: entry.text },
(0, import_react40.createElement)("span", { className: "bt-fb-dot", "data-level": entry.level, "aria-hidden": true }),
Expand Down
11 changes: 10 additions & 1 deletion core/src/client/feedback.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ export function FeedbackPage({ roster, actions, run, Section, Row }) {
const browserErrors = report ? clientErrors().map(entry => ({ ...entry, source: 'browser' })) : []
const entries = [...(report?.entries ?? []), ...browserErrors].sort((a, b) => new Date(b.time) - new Date(a.time))
const total = (report?.total ?? 0) + browserErrors.length
// The host's additive read failure (#2604): `entries` and `total` are 0 when the log could not
// be opened, which is not the same as a log with nothing in it. The reason is the host's own
// short word (EACCES), and it explains the empty list rather than any row in it.
const unread = typeof report?.unread === 'string' ? report.unread : undefined
// The list opens with eight errors; anything past them waits behind one control, so a
// burst of failures does not push the download and clear buttons off the page.
const shown = allErrors ? entries : entries.slice(0, 8)
Expand All @@ -127,7 +131,12 @@ export function FeedbackPage({ roster, actions, run, Section, Row }) {
issueLink ? [' ', h('a', { key: 'link', href: issueLink, target: '_blank', rel: 'noopener noreferrer' }, t('Open the issue'))] : null) : null,
h(Section, { key: 'errors', title: report ? t('Recent errors · {count}', { count: total }) : t('Recent errors') },
shown.length === 0
? h(Row, { label: t('No errors recorded.'), hint: t('Failed model calls and FlyBot problems show here, newest first.') })
? h(Row, {
label: unread === undefined
? t('No errors recorded.')
: t('The error log could not be read ({reason}); the errors it holds are not shown.', { reason: unread }),
hint: t('Failed model calls and FlyBot problems show here, newest first.'),
})
: shown.map((entry, index) => h('div', { key: `${entry.time}-${index}`, className: 'bt-set-row bt-fb-entry', title: entry.text },
h('span', { className: 'bt-fb-dot', 'data-level': entry.level, 'aria-hidden': true }),
h('div', { className: 'bt-set-copy' },
Expand Down
1 change: 1 addition & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -1214,6 +1214,7 @@ const ZH = {
'Recent errors': '最近的错误',
'Recent errors · {count}': '最近的错误 · {count}',
'No errors recorded.': '没有错误记录。',
'The error log could not be read ({reason}); the errors it holds are not shown.': '错误日志读不出来({reason});里面的错误没有显示。',
'Failed model calls and FlyBot problems show here, newest first.': '模型调用失败和 FlyBot 的问题会显示在这里,最新的在前。',
'Model call': '模型调用',
'This window': '这个窗口',
Expand Down
181 changes: 181 additions & 0 deletions core/test/feedback-unread-log.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// P-687: the Feedback page drew `No errors recorded.` whenever the host sent no rows, and the
// host's `unread` field (#2604) says when no rows is not the same as "nothing was recorded": the
// error log could not be opened, so the errors it holds are missing from the report. The page
// never read the field, so a refused log (EACCES) read as a clean one.
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 { FeedbackPage } from './src/client/feedback.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: children.flat(Infinity) } })
const all = (node, out = []) => {
if (Array.isArray(node)) { for (const child of node) all(child, out); return out }
if (node && typeof node === 'object') {
out.push(node)
for (const child of [node.props?.children].flat(Infinity)) all(child, out)
}
return out
}

function hooks() {
const states = []
const effectSlots = []
let cursor = 0
let dirty = false
let pending = []
const sameDeps = (a, b) => (a?.length === b?.length && (a ?? []).every((value, index) => value === b?.[index]))
return {
react: {
createElement: h,
Fragment: Symbol('Fragment'),
Component: class {},
useState: (initial) => {
const slot = cursor++
if (!(slot in states)) states[slot] = typeof initial === 'function' ? initial() : initial
const set = (value) => {
const next = typeof value === 'function' ? value(states[slot]) : value
if (next !== states[slot]) { states[slot] = next; dirty = true }
}
return [states[slot], set]
},
useEffect: (fn, deps) => {
const slot = cursor++
if (!effectSlots[slot] || !sameDeps(effectSlots[slot].deps, deps)) pending.push({ slot, fn, deps })
},
useLayoutEffect: (fn, deps) => { const slot = cursor++; if (!effectSlots[slot] || !sameDeps(effectSlots[slot].deps, deps)) pending.push({ slot, fn, deps }) },
useMemo: (make) => make(),
useCallback: (fn) => fn,
useRef: (value) => ({ current: value }),
},
begin() { cursor = 0; pending = [] },
end() {
for (const { slot, fn, deps } of pending) {
effectSlots[slot]?.cleanup?.()
const cleanup = fn()
effectSlots[slot] = { deps, cleanup: typeof cleanup === 'function' ? cleanup : undefined }
}
pending = []
},
get dirty() { return dirty },
clear() { dirty = false },
}
}

const Section = ({ title, children }) => h('section', { className: 'bt-set-section' }, h('h3', null, title), h('div', { className: 'bt-set-card' }, children))
const Row = ({ label, hint, children }) => h('div', { className: 'bt-set-row' },
h('div', { className: 'bt-set-copy' }, h('span', null, label), hint ? h('span', { className: 'bt-set-hint' }, hint) : null),
children ? h('div', { className: 'bt-set-control' }, children) : null)

const T0 = Date.UTC(2026, 10, 3, 4, 0, 0)
const errorEntries = count => Array.from({ length: count }, (_, index) => ({
time: T0 - index * 1000,
level: 'error',
text: `failure number ${index + 1}`,
source: 'host',
}))

async function mount({ entries = [], total = 0, unread } = {}) {
const runtime = hooks()
const report = {
entries,
total,
...(unread === undefined ? {} : { unread }),
markdown: '# diagnostics',
issuesUrl: 'https://github.com/ThinkFlowLab/FlyBot/issues/new',
}
const actions = {
diagnostics: () => Promise.resolve(report),
diagnosticsLog: () => Promise.resolve(''),
diagnosticsClear: () => Promise.resolve(),
}
const context = {
module: { exports: {} }, exports: {},
document: { addEventListener() {}, removeEventListener() {}, activeElement: null, querySelector: () => null },
navigator: { language: 'en' },
localStorage: { getItem: () => null, setItem() {} },
window: { addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) },
console, setTimeout, clearTimeout, Date, JSON, Promise, Symbol,
__flybot__: { slots: { entries: () => [], subscribe: () => () => {}, version: () => 0 }, call: () => {} },
require: (name) => {
if (name === 'react') return runtime.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}`)
},
}
context.module.exports = context.exports
runInNewContext(outputFiles[0].text, context)
const { FeedbackPage } = context.module.exports

const expand = (node) => {
if (Array.isArray(node)) return node.map(expand)
if (!node || typeof node !== 'object') return node
const children = [node.props?.children].flat(Infinity).map(expand)
if (typeof node.type === 'function') return expand(node.type({ ...node.props, children }))
return { type: node.type, props: { ...node.props, children } }
}
let tree = null
const render = () => {
runtime.begin()
tree = expand(FeedbackPage({ actions, run: fn => fn, Section, Row }))
runtime.end()
}
const settle = async () => {
for (let step = 0; step < 20; step += 1) {
runtime.clear()
render()
await new Promise(resolve => setTimeout(resolve, 0))
if (!runtime.dirty) return
}
throw new Error('the page never settled')
}

await settle()
const text = (node) => [node?.props?.children].flat(Infinity).filter(child => typeof child === 'string' || typeof child === 'number').join('')
const deepText = (node) => all(node).map(part => text(part)).filter(Boolean).join(' ')
const heading = () => all(tree).find(node => node.type === 'h3' && text(node).startsWith('Recent errors'))
const rows = () => all(tree).filter(node => node.props?.className === 'bt-set-row bt-fb-entry')
const emptyRow = () => all(tree).find(node => node.props?.className === 'bt-set-row'
&& deepText(node).startsWith('No errors recorded.'))
return {
get tree() { return tree },
get rows() { return rows() },
emptyRow,
title: () => text(heading()),
deepText: (node = tree) => deepText(node),
}
}

// RED today: the report says the log could not be read, and the page still says there is nothing to read.
test('P-687: an unread error log is named instead of reading as "No errors recorded."', async () => {
const page = await mount({ entries: [], total: 0, unread: 'EACCES' })
assert.equal(page.title(), 'Recent errors · 0', 'the title keeps the true count, not a guess')
assert.doesNotMatch(page.deepText(), /No errors recorded\./, `the empty state claims a clean log: ${page.deepText()}`)
assert.match(page.deepText(), /EACCES/, 'the reason the log could not be read is named')
assert.match(page.deepText(), /not shown|missing/i, 'the sentence says the errors it holds are not in the list')
})

// GREEN today, must stay: no `unread` field, no change — an empty log still reads as an empty log.
test('P-687 guard: a readable empty log keeps its sentence', async () => {
const page = await mount({ entries: [], total: 0 })
assert.equal(page.title(), 'Recent errors · 0')
assert.equal(page.deepText(page.emptyRow()), 'No errors recorded. Failed model calls and FlyBot problems show here, newest first.')
})

// GREEN today, must stay: rows the report does carry are drawn as before; `unread` changes only the
// empty state, so a report with browser errors and an unread host log does not hide its rows.
test('P-687 guard: an unread log does not replace rows that are there', async () => {
const page = await mount({ entries: errorEntries(3), total: 3, unread: 'EACCES' })
assert.equal(page.rows.length, 3)
assert.doesNotMatch(page.deepText(), /EACCES/, 'the notice belongs to the empty state only')
})
18 changes: 14 additions & 4 deletions patches/ds-bot/0001-core-fixes.patch
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ diff --git a/src/client/feedback.js b/src/client/feedback.js
const [preview, setPreview] = useState(false)
const [note, setNote] = useState('')
// Each action reads the report again, so it holds errors from the last few seconds.
@@ -82,12 +82,20 @@ export function FeedbackPage({ actions, run, Section, Row }) {
@@ -82,12 +82,24 @@ export function FeedbackPage({ actions, run, Section, Row }) {
download('ds-bot-diagnostics.log', text || 'No entries.\n')
setNote(t('Downloaded the log. Keys, tokens, and your home folder are hidden in it.'))
}))
Expand All @@ -594,13 +594,17 @@ diff --git a/src/client/feedback.js b/src/client/feedback.js
const entries = [...(report?.entries ?? []), ...browserErrors].sort((a, b) => new Date(b.time) - new Date(a.time))
const total = (report?.total ?? 0) + browserErrors.length
- const shown = entries.slice(0, 8)
+ // The host's additive read failure (#2604): `entries` and `total` are 0 when the log could not
+ // be opened, which is not the same as a log with nothing in it. The reason is the host's own
+ // short word (EACCES), and it explains the empty list rather than any row in it.
+ const unread = typeof report?.unread === 'string' ? report.unread : undefined
+ // The list opens with eight errors; anything past them waits behind one control, so a
+ // burst of failures does not push the download and clear buttons off the page.
+ const shown = allErrors ? entries : entries.slice(0, 8)
return [
h(Section, { key: 'report', title: t('Report a problem') },
h('div', { className: 'bt-set-row bt-set-paste bt-fb-what' },
@@ -99,23 +99,30 @@ export function FeedbackPage({ actions, run, Section, Row }) {
@@ -99,23 +99,35 @@ export function FeedbackPage({ actions, run, Section, Row }) {
h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => setPreview(!preview), 'aria-expanded': preview }, preview ? t('Hide') : t('Show')),
h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: copyReport }, t('Copy'))),
preview ? h('div', { className: 'bt-set-row bt-fb-preview' }, h('pre', { tabIndex: 0, 'aria-label': t('Diagnostics') }, report?.markdown || t('Loading…'))) : null,
Expand All @@ -613,7 +617,12 @@ diff --git a/src/client/feedback.js b/src/client/feedback.js
h(Section, { key: 'errors', title: report ? t('Recent errors · {count}', { count: total }) : t('Recent errors') },
shown.length === 0
- ? h(Row, { label: t('No errors recorded.'), hint: t('Failed model calls and DS Bot problems show here, newest first.') })
+ ? h(Row, { label: t('No errors recorded.'), hint: t('Failed model calls and FlyBot problems show here, newest first.') })
+ ? h(Row, {
+ label: unread === undefined
+ ? t('No errors recorded.')
+ : t('The error log could not be read ({reason}); the errors it holds are not shown.', { reason: unread }),
+ hint: t('Failed model calls and FlyBot problems show here, newest first.'),
+ })
: shown.map((entry, index) => h('div', { key: `${entry.time}-${index}`, className: 'bt-set-row bt-fb-entry', title: entry.text },
h('span', { className: 'bt-fb-dot', 'data-level': entry.level, 'aria-hidden': true }),
h('div', { className: 'bt-set-copy' },
Expand Down Expand Up @@ -1487,7 +1496,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js
'Report on GitHub': '在 GitHub 上报告',
'Copied the diagnostics.': '已复制诊断信息。',
'Could not copy. Select the text below and copy it.': '无法复制。请选中下面的文字手动复制。',
@@ -735,10 +735,12 @@ const ZH = {
@@ -735,10 +735,13 @@ const ZH = {
'Opened a new GitHub issue. Copy the diagnostics below into it.': '已打开新的 GitHub issue。请把下面的诊断信息复制进去。',
'Downloaded the log. Keys, tokens, and your home folder are hidden in it.': '已下载日志。里面的密钥、令牌和用户目录都已隐藏。',
'Cleared the error log.': '已清空错误日志。',
Expand All @@ -1497,6 +1506,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js
'Recent errors · {count}': '最近的错误 · {count}',
'No errors recorded.': '没有错误记录。',
- 'Failed model calls and DS Bot problems show here, newest first.': '模型调用失败和 DS Bot 的问题会显示在这里,最新的在前。',
+ 'The error log could not be read ({reason}); the errors it holds are not shown.': '错误日志读不出来({reason});里面的错误没有显示。',
+ 'Failed model calls and FlyBot problems show here, newest first.': '模型调用失败和 FlyBot 的问题会显示在这里,最新的在前。',
'Model call': '模型调用',
'This window': '这个窗口',
Expand Down
Loading
Loading