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/2266-error-list-show-more.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Settings → Feedback's error list now offers **Show more** under the newest eight errors: the title has always reported the whole log (`Recent errors · 60`) while the list stopped at eight with nothing to open the rest, so the ninth error could not be read at all. Expanding draws every loaded error and folds back to **Show less**; a report of eight or fewer keeps the list and its title exactly as they were.
13 changes: 12 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -18677,6 +18677,7 @@ var SOURCES = { turn: "Model call", host: "FlyBot", api: "FlyBot", browser: "Thi
function FeedbackPage({ actions, run, Section, Row: Row4 }) {
const [what, setWhat] = (0, import_react40.useState)("");
const [report, setReport] = (0, import_react40.useState)(null);
const [allErrors, setAllErrors] = (0, import_react40.useState)(false);
const [preview, setPreview] = (0, import_react40.useState)(false);
const [note, setNote] = (0, import_react40.useState)("");
const fetchReport = () => actions.diagnostics({ client: clientFacts(), clientErrors: clientErrors() }).then((next) => {
Expand Down Expand Up @@ -18718,7 +18719,7 @@ function FeedbackPage({ 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 shown = entries2.slice(0, 8);
const shown = allErrors ? entries2 : entries2.slice(0, 8);
return [
(0, import_react40.createElement)(
Section,
Expand Down Expand Up @@ -18768,6 +18769,16 @@ function FeedbackPage({ actions, run, Section, Row: Row4 }) {
),
entry.code ? (0, import_react40.createElement)("span", { className: "bt-tag bt-fb-code" }, entry.status ? `${entry.code} ${entry.status}` : entry.code) : null
)),
entries2.length > 8 ? (0, import_react40.createElement)(
"div",
{ key: "more", className: "bt-set-row bt-fb-more-row" },
(0, import_react40.createElement)("button", {
type: "button",
className: "bt-soft bt-soft-sm bt-fb-more",
"aria-expanded": allErrors,
onClick: () => setAllErrors(!allErrors)
}, t(allErrors ? "Show less" : "Show more"))
) : null,
(0, import_react40.createElement)(
Row4,
{ label: t("Error log"), hint: t("Kept on this computer across restarts, up to about 1 MB.") },
Expand Down
12 changes: 11 additions & 1 deletion core/src/client/feedback.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const SOURCES = { turn: 'Model call', host: 'FlyBot', api: 'FlyBot', browser: 'T
export function FeedbackPage({ actions, run, Section, Row }) {
const [what, setWhat] = useState('')
const [report, setReport] = useState(null)
const [allErrors, setAllErrors] = useState(false)
const [preview, setPreview] = useState(false)
const [note, setNote] = useState('')
// Each action reads the report again, so it holds errors from the last few seconds.
Expand Down Expand Up @@ -87,7 +88,9 @@ export function FeedbackPage({ 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
const shown = entries.slice(0, 8)
// 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' },
Expand All @@ -113,6 +116,13 @@ export function FeedbackPage({ actions, run, Section, Row }) {
h('span', { className: 'bt-fb-text' }, entrySummary(entry)),
h('span', { className: 'bt-set-hint' }, [whenText(entry.time), entry.bot, entry.where, t(SOURCES[entry.source] ?? 'FlyBot')].filter(Boolean).join(' · '))),
entry.code ? h('span', { className: 'bt-tag bt-fb-code' }, entry.status ? `${entry.code} ${entry.status}` : entry.code) : null)),
entries.length > 8
? h('div', { key: 'more', className: 'bt-set-row bt-fb-more-row' },
h('button', {
type: 'button', className: 'bt-soft bt-soft-sm bt-fb-more', 'aria-expanded': allErrors,
onClick: () => setAllErrors(!allErrors),
}, t(allErrors ? 'Show less' : 'Show more')))
: null,
h(Row, { label: t('Error log'), hint: t('Kept on this computer across restarts, up to about 1 MB.') },
h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: downloadLog }, t('Download')),
h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: clearLog, disabled: !report || total === 0 }, t('Clear')))),
Expand Down
203 changes: 203 additions & 0 deletions core/test/feedback-errors-more.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// #2266: the Feedback page's error list reported the whole count in its title
// (`Recent errors · 60`) while drawing eight rows and offering nothing to reach the rest,
// so a person reading a burst of failures could not see the ninth error at all. The list
// now draws the first eight and a `Show more` control that expands it to every loaded row,
// with `Show less` folding it back; a report of eight or fewer errors keeps the list and
// the title exactly as they were, and the control is not drawn.
//
// This test runs the real hooks: `useState` below re-renders the page, `useEffect` runs the
// report load, and a click is the control's own `onClick`. A `useEffect() {}` stub would
// never show the list filling in.
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
}

// A tiny hooks runtime: state slots survive re-renders, setters mark the tree dirty, and
// effects run after the tree is committed (and only when their deps change).
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',
}))

// The host read caps its payload at fifty entries while `total` counts the whole log, so a
// sixty-error log arrives as fifty rows and a total of sixty — the shape the probe showed.
async function mount({ entries, total } = {}) {
const runtime = hooks()
const report = {
entries,
total,
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

// The renderer's one job: call the function components (Section, Row) the page hands
// its children to, so the drawn tree is what the browser would get.
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 find = predicate => all(tree).find(predicate)
const rows = () => all(tree).filter(node => node.props?.className === 'bt-set-row bt-fb-entry')
const more = () => find(node => typeof node.props?.className === 'string' && node.props.className.split(' ').includes('bt-fb-more'))
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 = () => find(node => node.type === 'h3' && text(node).startsWith('Recent errors'))
return {
get rows() { return rows() },
get more() { return more() },
get heading() { return heading() },
text, deepText,
title: () => text(heading()),
async click() { more().props.onClick({}); await settle() },
}
}

test('#2266: a sixty-error report draws eight rows and offers the rest', async () => {
const page = await mount({ entries: errorEntries(50), total: 60 })
assert.equal(page.title(), 'Recent errors · 60', 'the title still reports the whole log')
assert.equal(page.rows.length, 8, 'the list still opens with eight rows')
assert.ok(page.more, 'the list offers a way to see the remaining errors')
assert.equal(page.more.props['aria-expanded'], false, 'the control says the rest is not drawn yet')
assert.equal(page.text(page.more).trim(), 'Show more')
})

test('#2266: Show more draws the errors past the eighth, and Show less folds them back', async () => {
const page = await mount({ entries: errorEntries(50), total: 60 })
await page.click()
assert.equal(page.rows.length, 50, 'every loaded error is drawn after the click')
const texts = page.rows.map(row => page.deepText(row))
assert.ok(texts.some(value => value.includes('failure number 9')), 'the ninth error is reachable')
assert.equal(page.more.props['aria-expanded'], true)
assert.equal(page.text(page.more).trim(), 'Show less')
await page.click()
assert.equal(page.rows.length, 8, 'Show less folds the list back to eight rows')
assert.equal(page.text(page.more).trim(), 'Show more')
})

test('#2266: eight or fewer errors draw no control and keep the title as it was', async () => {
const page = await mount({ entries: errorEntries(3), total: 3 })
assert.equal(page.title(), 'Recent errors · 3')
assert.equal(page.rows.length, 3)
assert.equal(page.more, undefined, 'nothing to expand means no control')

const exact = await mount({ entries: errorEntries(8), total: 8 })
assert.equal(exact.title(), 'Recent errors · 8')
assert.equal(exact.rows.length, 8)
assert.equal(exact.more, undefined, 'exactly eight rows are all drawn, so no control')
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl
- Read-only banner: when another dsh holds the team (`roster.readOnly` a non-empty string), the sidebar draws the host's own sentence and, beside it, one **Reload** control in both shapes (wide and rail; the control stays outside the `role="status"` line so a live region remains text). It calls `actions.reloadTeam`, which re-reads the whole team (`state` without `since`, see `stateRequest` / `stateAnswerIsStale` in `sources.js`) so the banner and every refused control clear once the other holder let go, without a page reload that would drop what is in the composer. Tests: `core/test/sidebar-readonly-reload.test.js`, `core/test/client-reload-state.test.js`.
- Chat context rows: a tool the plugin registers at install draws one `tool-addition` row, and `cells.js` keeps every name in `TEAM_TOOLS` out of the chat (`SILENT_REGISTRATION_TOOLS`, #1101) — the Project Assistant's `project_digest` joins them (#1712), so its 09:00 Session shows the digest, not its registration.
- A Settings dialog takes focus when it opens (`useDialogFocus` in `core/src/client/overlay-hooks.js`; the `.bt-settings` dialog carries `tabIndex -1` and a ref) and gives it back to the still-connected opener when it closes; it is not a focus trap, so Tab can still leave the dialog for the page behind the scrim (#2251).

- Settings → Feedback's error list (`feedback.js`) still titles the block with the whole log (`Recent errors · 60`) but lists only the newest eight, so when the report holds more rows one `Show more` control (`aria-expanded`) sits under them and draws every loaded error, folding back to `Show less`; a report of eight or fewer draws no control (#2266).

## Does not own

Expand Down
28 changes: 26 additions & 2 deletions patches/ds-bot/0001-core-fixes.patch
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,7 @@ diff --git a/src/client/connectors.js b/src/client/connectors.js
diff --git a/src/client/feedback.js b/src/client/feedback.js
--- a/src/client/feedback.js
+++ b/src/client/feedback.js
@@ -37,7 +37,7 @@ function entrySummary(entry) {
@@ -37,11 +37,12 @@ function entrySummary(entry) {
return entry.text
}

Expand All @@ -535,7 +535,23 @@ diff --git a/src/client/feedback.js b/src/client/feedback.js

export function FeedbackPage({ actions, run, Section, Row }) {
const [what, setWhat] = useState('')
@@ -99,19 +99,19 @@ export function FeedbackPage({ actions, run, Section, Row }) {
const [report, setReport] = useState(null)
+ const [allErrors, setAllErrors] = useState(false)
const [preview, setPreview] = useState(false)
const [note, setNote] = useState('')
// Each action reads the report again, so it holds errors from the last few seconds.
@@ -87,7 +87,9 @@ export function FeedbackPage({ 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
- const shown = entries.slice(0, 8)
+ // 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,20 +99,27 @@ 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 @@ -556,8 +572,16 @@ diff --git a/src/client/feedback.js b/src/client/feedback.js
- h('span', { className: 'bt-set-hint' }, [whenText(entry.time), entry.bot, entry.where, t(SOURCES[entry.source] ?? 'DS Bot')].filter(Boolean).join(' · '))),
+ h('span', { className: 'bt-set-hint' }, [whenText(entry.time), entry.bot, entry.where, t(SOURCES[entry.source] ?? 'FlyBot')].filter(Boolean).join(' · '))),
entry.code ? h('span', { className: 'bt-tag bt-fb-code' }, entry.status ? `${entry.code} ${entry.status}` : entry.code) : null)),
+ entries.length > 8
+ ? h('div', { key: 'more', className: 'bt-set-row bt-fb-more-row' },
+ h('button', {
+ type: 'button', className: 'bt-soft bt-soft-sm bt-fb-more', 'aria-expanded': allErrors,
+ onClick: () => setAllErrors(!allErrors),
+ }, t(allErrors ? 'Show less' : 'Show more')))
+ : null,
h(Row, { label: t('Error log'), hint: t('Kept on this computer across restarts, up to about 1 MB.') },
h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: downloadLog }, t('Download')),
h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: clearLog, disabled: !report || total === 0 }, t('Clear')))),
@@ -133,9 +133,10 @@ export function ErrorToast({ useUi, actions }) {
return () => { clearTimeout(fade); clearTimeout(gone) }
}, [toast?.token])
Expand Down
Loading
Loading