perf: reduce streaming render cost and refresh activity page on demand

- Segment streaming Markdown so completed blocks are reused and only the growing tail is re-parsed; fall back to whole-document parsing for document-wide constructs
- Memoize conversation sidebar rows and cache per-conversation derived activity, ordering and pending approvals
- Activity page loads on entry, refreshes every 15 s while visible, and has a single header refresh button
- Include pending settings, model download source, device sharing and knowledge gateway changes in the working tree
- Add Electron streaming input benchmark and regression tests
This commit is contained in:
mesalogo
2026-10-01 21:49:09 +08:00
parent 016f987026
commit c8f327fc4e
75 changed files with 3042 additions and 905 deletions
+8
View File
@@ -35,6 +35,14 @@ void app.whenReady().then(async () => {
try {
await win.loadURL(process.env.GB_SHARING_RENDERER!)
await wait(`!![...document.querySelectorAll('article')].find(e => e.textContent.includes('Device Sharing'))`)
assert.equal(await js(`[...document.querySelectorAll('article')].find(e=>e.textContent.includes('Device Sharing')).querySelector('button').disabled`), true)
await js(`document.querySelector('[aria-label="Device Sharing (Technical Preview) Application settings"]').click()`)
await wait(`!!document.querySelector('input[role="switch"]')`)
assert.equal(await js(`document.querySelectorAll('input[role="switch"]').length`), 1)
await js(`document.querySelector('input[role="switch"]').click()`)
await wait(`document.querySelector('input[role="switch"]').checked`)
await js(`document.querySelector('[aria-label="Back to application center"]').click()`)
await wait(`!![...document.querySelectorAll('article')].find(e => e.textContent.includes('Device Sharing'))`)
await js(`[...document.querySelectorAll('article')].find(e=>e.textContent.includes('Device Sharing')).querySelector('button').click()`)
await click('Register this device')
await wait(`!!document.querySelector('#sharing-devices + article') || document.querySelector('[aria-labelledby="sharing-devices"]').textContent.includes('Sharing smoke device')`)
+3 -2
View File
@@ -9,12 +9,13 @@ import '../../src/renderer/src/styles.css'
const settings = applicationSettingsSchema.parse({ checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false })
function Harness(): React.JSX.Element {
const [currentSettings, setSettings] = useState(settings)
const [open, setOpen] = useState(true)
const [page, setPage] = useState(false)
return <UiLocaleProvider initialPreference="en-US">
{open && <ApplicationCenter settings={settings} pending={false} onClose={() => setOpen(false)}
{open && <ApplicationCenter settings={currentSettings} pending={false} onClose={() => setOpen(false)}
onOpen={id => { if (id === 'device-sharing') { setPage(true); setOpen(false) } }}
onUpdate={async () => false} onRetry={() => undefined} />}
onUpdate={async patch => { setSettings(current => ({ ...current, ...patch })); return true }} onRetry={() => undefined} />}
{page && <DeviceSharingPage notify={input => { document.documentElement.dataset.notification = input.message }} />}
</UiLocaleProvider>
}
+142
View File
@@ -0,0 +1,142 @@
import { app, BrowserWindow } from 'electron'
import assert from 'node:assert/strict'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { setTimeout as delay } from 'node:timers/promises'
import { AgentEventBuffer } from '../../src/main/agent-event-buffer'
import { longAnswerCorpus } from './streaming-markdown-corpus'
const directory = process.env.GOODBUDDY_STREAMING_INPUT_DIRECTORY!
const epoch = () => performance.timeOrigin + performance.now()
const duration = 5_000
const chunkPeriod = 8
const inputPeriod = 71
// End on a complete section and add math, rather than truncating a code fence/table.
const sectionEnd = longAnswerCorpus.indexOf('## Deployment review', 22_000)
const answer = longAnswerCorpus.slice(0, sectionEnd) + '\nInline \\(x^2 + y^2\\) and display math:\n\n\\[E = mc^2\\]\n'
const chunkCount = duration / chunkPeriod
const chunks = Array.from({ length: chunkCount }, (_, index) => answer.slice(
Math.floor(answer.length * index / chunkCount), Math.floor(answer.length * (index + 1) / chunkCount)))
const inputOffsets = Array.from({ length: Math.floor((duration - 200) / inputPeriod) }, (_, index) => 103 + index * inputPeriod)
const draft = inputOffsets.map((_, index) => String.fromCharCode(97 + index % 26)).join('')
const stats = (values: number[]) => {
const sorted = [...values].sort((a, b) => a - b)
return { n: sorted.length, median: (sorted[Math.floor((sorted.length - 1) / 2)]! + sorted[Math.floor(sorted.length / 2)]!) / 2,
p95: sorted[Math.ceil(sorted.length * 0.95) - 1]!, max: sorted.at(-1)! }
}
app.setPath('userData', join(directory, 'profile'))
app.commandLine.appendSwitch('force-device-scale-factor', '1')
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: true, width: 1100, height: 800,
webPreferences: { preload: join(directory, 'preload.cjs'), sandbox: true,
contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
const errors: string[] = []
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
await win.loadFile(join(directory, 'renderer', 'streaming-input-page.html'))
win.focus()
const evaluate = (code: string) => win.webContents.executeJavaScript(`window.streamingInput.${code}`)
const reference = await evaluate(`reset(${JSON.stringify(answer)})`)
assert(reference.includes('Deployment review'))
const results: { interval: number; updates: number; longTasks: number;
raw: { sendToInput: number[]; inputToFrame: number[] } }[] = []
// Warm one full growing answer, then rotate and reverse the measured order.
const order = [32, 16, 50, 32, 80, 80, 32, 50, 16]
for (const [position, interval] of order.entries()) {
await evaluate('reset()')
// Estimate process clock offset while idle; minimum RTT bounds the timestamp uncertainty.
const calibration = []
for (let sample = 0; sample < 12; sample++) {
const before = epoch()
const renderer = await evaluate('clock()')
const after = epoch()
calibration.push({ offset: renderer - (before + after) / 2, rtt: after - before })
}
const clock = calibration.sort((a, b) => a.rtt - b.rtt)[0]!
const sent: number[] = []
const chunkLateness: number[] = []
const inputLateness: number[] = []
let flushes = 0
let firstFlush = 0
const buffer = new AgentEventBuffer({ flushIntervalMs: interval, onEvent(event) {
if (event.type !== 'text') return
flushes++
if (!firstFlush) firstFlush = epoch()
win.webContents.send('streaming-input:chunk', event.delta)
} })
const start = epoch()
// All deadlines are scheduled in main up front. Renderer blockage cannot slow the producer or typing.
const arrivals = chunks.map((delta, index) => new Promise<void>(resolve => {
const deliver = () => {
chunkLateness.push(epoch() - start - index * chunkPeriod)
buffer.push({ type: 'text', requestId: 'benchmark', delta })
if (index === 0) buffer.flush()
resolve()
}
if (index === 0) deliver()
else setTimeout(deliver, Math.max(0, start + index * chunkPeriod - epoch()))
}))
const typing = inputOffsets.map((offset, index) => new Promise<void>(resolve => {
setTimeout(() => {
sent.push(epoch())
inputLateness.push(sent.at(-1)! - start - offset)
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: draft[index]! })
win.webContents.sendInputEvent({ type: 'char', keyCode: draft[index]! })
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: draft[index]! })
resolve()
}, Math.max(0, start + offset - epoch()))
}))
await Promise.all([...arrivals, ...typing, delay(Math.max(0, start + duration - epoch()))])
buffer.close()
const producerDuration = epoch() - start
let ready = false
for (let poll = 0; poll < 400; poll++) {
const status = await evaluate('status()')
if (status.length === answer.length && status.inputs === draft.length && status.frames === draft.length) {
ready = true
break
}
await delay(10)
}
assert(ready, 'renderer must drain the entire independently scheduled stream and native input')
const result = await evaluate('result()')
assert.equal(result.committed, answer)
assert.equal(result.domText, reference)
assert.equal(result.draft, draft)
assert.equal(result.committedDraft, draft)
assert.equal(result.focused, true)
assert.equal(result.received, flushes)
assert.equal(result.inputs.length, sent.length)
for (const [index, input] of result.inputs.entries()) {
assert.equal(input.value, draft.slice(0, index + 1))
assert.equal(input.trusted, true)
}
const sendToInput = result.inputs.map((input: { at: number }, index: number) => input.at - clock.offset - sent[index]!)
const inputToFrame = result.inputs.map((input: { at: number; frame: number }) => input.frame - input.at)
const row = { interval, run: position === 0 ? 'warmup' : position <= 4 ? 1 : 2,
sendToInputMs: stats(sendToInput), inputToFrameMs: stats(inputToFrame),
sendToFrameMs: stats(sendToInput.map((value: number, index: number) => value + inputToFrame[index])),
updates: result.updates, flushes, longTasks: result.tasks.length,
longTaskMs: result.tasks.length ? stats(result.tasks) : null,
longTaskTotalMs: result.tasks.reduce((sum: number, value: number) => sum + value, 0),
producerDurationMs: producerDuration, firstFlushMs: firstFlush - start,
firstCommitMs: result.firstCommit - clock.offset - start,
lastCommitMs: result.lastCommit - clock.offset - start,
chunkLatenessMs: stats(chunkLateness), inputLatenessMs: stats(inputLateness),
clockUncertaintyMs: clock.rtt / 2, exactTextDraftFocus: true }
console.log('Streaming input run ' + JSON.stringify(row))
if (position > 0) results.push({ ...row, raw: { sendToInput, inputToFrame } })
}
assert.deepEqual(errors, [])
const aggregate = [16, 32, 50, 80].map(interval => {
const runs = results.filter(row => row.interval === interval)
return { interval, sendToInputMs: stats(runs.flatMap(row => row.raw.sendToInput)),
inputToFrameMs: stats(runs.flatMap(row => row.raw.inputToFrame)),
sendToFrameMs: stats(runs.flatMap(row => row.raw.sendToInput.map((value, index) => value + row.raw.inputToFrame[index]!))),
updates: runs.map(row => row.updates), longTasks: runs.map(row => row.longTasks) }
})
console.log('Streaming input summary ' + JSON.stringify({ electron: process.versions.electron,
chromium: process.versions.chrome, renderer: 'production ChatTimeline + controlled textarea (not App)',
bytes: Buffer.byteLength(answer), durationMs: duration, chunks: chunks.length, chunkPeriodMs: chunkPeriod,
inputPeriodMs: inputPeriod, measuredOrder: order.slice(1), aggregate }))
app.exit(0)
}).catch(error => { console.error(error); app.exit(1) })
+3
View File
@@ -0,0 +1,3 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Streaming native input benchmark</title></head>
<body><div id="root"></div><script type="module" src="./streaming-input-renderer.tsx"></script></body></html>
+7
View File
@@ -0,0 +1,7 @@
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('streamingInputBridge', {
onChunk: (callback: (delta: string) => void) => {
ipcRenderer.on('streaming-input:chunk', (_event, delta) => callback(delta))
}
})
+101
View File
@@ -0,0 +1,101 @@
import { useLayoutEffect, useMemo, useState } from 'react'
import { flushSync } from 'react-dom'
import { createRoot } from 'react-dom/client'
import { ChatTimeline, type Message } from '../../src/renderer/src/ChatTimeline'
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
import '../../src/renderer/src/fonts'
import '../../src/renderer/src/styles.css'
const epoch = () => performance.timeOrigin + performance.now()
const inputs: { at: number; frame: number; value: string; trusted: boolean }[] = []
const tasks: number[] = []
let collecting = false
let committed = ''
let committedDraft = ''
let updates = 0
let received = 0
let firstCommit = 0
let lastCommit = 0
let setContent: (update: string | ((previous: string) => string)) => void
let setDraft: (value: string) => void
const observer = new PerformanceObserver(list => {
if (collecting) tasks.push(...list.getEntries().map(entry => entry.duration))
})
observer.observe({ type: 'longtask' })
const callbacks = {
onArticleRef: () => {}, onCopyMessage: async () => true,
onDownloadImage: () => {}, onOpenCitationContext: async () => {},
onOpenCitationSource: async () => {}, onOpenImage: () => {},
onRespondApproval: async () => {}, onRespondQuestion: async () => {},
onRetry: () => {}, onRevealEarlier: () => {}
}
const artifacts = new Map()
const initial: Message = { id: 'answer', role: 'assistant', content: '', createdAt: 1, state: 'streaming' }
function Bench() {
const [content, updateContent] = useState('')
const [draft, updateDraft] = useState('')
// Preserve ChatTimeline's production memo boundary when only the composer changes.
const messages = useMemo(() => [{ ...initial, content }], [content])
setContent = updateContent
setDraft = updateDraft
useLayoutEffect(() => {
committed = content
if (collecting && content) {
updates++
lastCommit = epoch()
if (!firstCommit) firstCommit = lastCommit
}
}, [content])
useLayoutEffect(() => { committedDraft = draft }, [draft])
return <UiLocaleProvider initialPreference="en-US">
<main style={{ height: '100vh', display: 'flex', flexDirection: 'column', padding: 16 }}>
<section id="answer" style={{ flex: 1, overflow: 'auto' }}>
<ChatTimeline {...callbacks} artifactById={artifacts} conversationId="benchmark"
hiddenMessageCount={0} isUnusedConversation={false} locale="en-US"
messageStartIndex={0} messages={messages} totalMessageCount={1} />
</section>
<textarea aria-label="Draft" id="draft" value={draft} rows={3}
onInput={event => {
if (!collecting) return
const sample = { at: epoch(), frame: 0, value: event.currentTarget.value,
trusted: event.nativeEvent.isTrusted }
inputs.push(sample)
// Callback execution time includes work queued before this frame; the rAF timestamp does not.
requestAnimationFrame(() => { sample.frame = epoch() })
}} onChange={event => updateDraft(event.target.value)} />
</main>
</UiLocaleProvider>
}
flushSync(() => createRoot(document.getElementById('root')!).render(<Bench />))
const bridge = (window as unknown as {
streamingInputBridge: { onChunk: (callback: (delta: string) => void) => void }
}).streamingInputBridge
bridge.onChunk(delta => { received++; setContent(previous => previous + delta) })
const nextFrame = () => new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
Object.assign(window, { streamingInput: {
async reset(content = '') {
collecting = false
flushSync(() => { setContent(content); setDraft('') })
await document.fonts.ready
await nextFrame()
await nextFrame()
inputs.length = 0
tasks.length = 0
observer.takeRecords()
updates = received = firstCommit = lastCommit = 0
document.getElementById('draft')!.focus()
collecting = true
return document.getElementById('answer')!.textContent
},
clock: epoch,
status: () => ({ length: committed.length, inputs: inputs.length, frames: inputs.filter(input => input.frame).length }),
async result() {
await nextFrame()
await nextFrame()
tasks.push(...observer.takeRecords().map(entry => entry.duration))
collecting = false
return { committed, committedDraft, domText: document.getElementById('answer')!.textContent,
draft: (document.getElementById('draft') as HTMLTextAreaElement).value,
focused: document.activeElement?.id === 'draft', inputs, tasks, updates, received, firstCommit, lastCommit }
}
} })
+118
View File
@@ -0,0 +1,118 @@
# Native input during streaming
Run with `npx vitest run tests/streaming-input.electron.test.ts`.
The final validated run took 53.37 seconds on Windows, Electron 43.2.0,
Chromium 150.0.7871.129. Repository typecheck and lint passed.
## Method
- Vite production build of the real `ChatTimeline` and `MarkdownRenderer`,
production React, bundled fonts/styles, and a controlled React textarea.
The message array remains stable across draft-only changes.
- Real `AgentEventBuffer`, bundled into Electron main. The first chunk is
explicitly flushed immediately for every interval, including warmup.
- Identical 22,716-byte growing Markdown answer: prose, headings, lists,
fenced TypeScript, tables, quotation, inline math, and display math.
- 625 equal-throughput chunks scheduled independently in main over 5 seconds
(8 ms target spacing, approximately 4.54 KB/s).
- 67 native key sequences per run, independently scheduled in main every
71 ms. Input events must be trusted, preserve every draft prefix, and end
with the exact draft and focus intact. No provider/model calls.
- One full warmup at 32 ms. Measured order: `16, 50, 32, 80`, then
`80, 32, 50, 16`. Two runs and 134 input samples per interval.
- Send-to-input uses the timestamp immediately before native key dispatch
and the renderer input handler timestamp. Twelve idle clock probes precede
each run; the minimum-RTT midpoint estimates process clock offset.
Final measured clock uncertainty was at most 0.062 ms.
- Input-to-frame ends at execution of the next requestAnimationFrame callback,
not its supplied frame timestamp. This is a pre-paint responsiveness proxy,
not physical screen presentation latency.
- Counts include committed answer updates and Chromium long tasks (over 50 ms).
Final committed Markdown and DOM text must exactly match the source and an
independently rendered static reference, respectively.
- Renderer timers never drive arrivals or typing. Output includes main timer
lateness, producer duration, first flush/commit, and final commit times.
Assertions cover correctness, not machine-dependent performance thresholds.
## Final Results
All latency cells are **median / p95 / maximum**, in milliseconds. Pooled
percentiles are computed from samples, not averages of per-run percentiles.
| Interval | Send to input | Input to next frame | Send to next frame | Answer updates, runs 1 / 2 | Long tasks |
| --- | --- | --- | --- | --- | --- |
| 16 ms | 0.80 / 12.54 / 18.92 | 0.80 / 13.30 / 27.90 | 3.23 / 15.57 / 28.64 | 164 / 164 | 0 / 0 |
| 32 ms | 0.71 / 11.33 / 24.83 | 0.60 / 16.20 / 25.40 | 2.17 / 17.34 / 26.20 | 110 / 109 | 0 / 0 |
| 50 ms | 0.72 / 4.35 / 23.69 | 0.50 / 17.80 / 22.30 | 1.28 / 18.85 / 24.29 | 82 / 82 | 0 / 0 |
| 80 ms | 0.71 / 10.17 / 20.81 | 0.40 / 7.70 / 14.20 | 1.16 / 13.63 / 21.51 | 55 / 55 | 0 / 0 |
Per-run p95 values make the variability visible:
| Interval | Send to input, runs 1 / 2 | Input to frame, runs 1 / 2 | Send to frame, runs 1 / 2 |
| --- | --- | --- | --- |
| 16 ms | 6.51 / 13.44 | 13.30 / 6.80 | 14.81 / 16.68 |
| 32 ms | 11.32 / 11.33 | 16.60 / 11.40 | 17.82 / 16.04 |
| 50 ms | 1.11 / 4.35 | 17.80 / 15.40 | 18.51 / 20.18 |
| 80 ms | 10.44 / 4.93 | 10.60 / 7.40 | 13.63 / 12.90 |
All nine runs, including warmup, preserved exact final Markdown, rendered DOM
text, draft, each typed prefix, and textarea focus. Every emitted buffer flush
was received. Measured first flushes occurred within 0.08 ms of producer start;
first commits within 1.58 ms. Producer durations were 5,002.79-5,013.54 ms.
## Interpretation
**50 ms is the smallest candidate with a material send-to-input p95 improvement**
in this measurement: 65% below 16 ms, with half as many answer updates. It is
not an overall latency win: send-to-next-frame p95 is 21% higher than at 16 ms.
80 ms has the best pooled send-to-next-frame p95 (13.63 ms versus 15.57 ms at
16 ms), and the best input-to-frame tail. That approximately 1.94 ms end-to-end
p95 saving costs two thirds of the answer updates. Two short runs do not
establish a robust benefit sufficient to recommend raising the production
default to 80 ms. This benchmark does not justify claiming 32 ms materially
improves tail input latency, either. Preserve the production default unless
the chosen target is specifically send-to-input latency; for that target,
50 ms is the smallest measured candidate to investigate in the complete App.
## After segmented Markdown rendering
`MarkdownRenderer` now splits Markdown at safe top-level block boundaries and
memoizes each segment, so a streaming update re-parses only the growing tail.
Same benchmark, same machine, same order (`16, 50, 32, 80, 80, 32, 50, 16`),
two runs and 134 input samples per interval; exact text, draft, every typed
prefix and focus were preserved in all nine runs, with zero long tasks.
| Interval | Send to input p95, before / after | Send to next frame p95, before / after | Answer updates |
| --- | --- | --- | --- |
| 16 ms | 12.54 / 1.23 | 15.57 / 7.94 | 163 / 164 |
| 32 ms | 11.33 / 1.27 | 17.34 / 8.23 | 109 / 110 |
| 50 ms | 4.35 / 1.29 | 18.85 / 7.21 | 82 / 82 |
| 80 ms | 10.17 / 1.39 | 13.63 / 7.57 | 55 / 55 |
With segmentation the interval no longer materially changes input latency,
so the production 16 ms default is retained: streaming cadence is unchanged.
`tests/streaming-markdown.electron.test.ts` adds a streaming-delta case: 120
characters appended per update to an approximately 80 KB answer, on persistent
roots. Whole-document rendering took median 59.2 ms (p95 65.4 ms); segmented
rendering took median 2.4 ms (p95 4.2 ms), with DOM equality checked against
whole-document rendering throughout.
## Limits
This mounts production ChatTimeline, not App. It excludes App event reduction,
conversation history, the production composer, and App auto-scroll. The answer
pane stays at its initial scroll position. DOM work occurs for the entire
answer, but this does not measure continuously painting its moving bottom edge.
The static reference checks DOM text equivalence, not pixel equivalence.
Windows timers coalesced: chunk scheduling p95 lateness was approximately
12.7-13.5 ms, and native-input scheduling p95 lateness 12.4-14.1 ms. Therefore
configured 16 ms did not produce 62.5 updates/second: it produced approximately
32.8. Lateness is reported separately and excluded from send-to-input latency,
which starts at actual dispatch. The producer never waits for the renderer.
This is one machine without CPU throttling and one moderate-throughput workload.
There were no long tasks at any interval. A slower machine, larger history,
App auto-scroll, or faster generation may change the tradeoff substantially.
+46 -4
View File
@@ -86,7 +86,7 @@ async function run() {
for (let round = 0; round < 4; round++) {
render(current.MarkdownRenderer, '')
flushSync(() => originalRoot.render(<UiLocaleProvider initialPreference="en-US">
<original.MarkdownRenderer>{''}</original.MarkdownRenderer>
<original.UnsegmentedMarkdownRenderer>{''}</original.UnsegmentedMarkdownRenderer>
</UiLocaleProvider>))
for (const [index, length] of prefixLengths.entries()) {
const prefix = longAnswerCorpus.slice(0, length)
@@ -98,7 +98,7 @@ async function run() {
const start = performance.now()
if (variant === 'original') {
flushSync(() => originalRoot.render(<UiLocaleProvider initialPreference="en-US">
<original.MarkdownRenderer>{prefix}</original.MarkdownRenderer>
<original.UnsegmentedMarkdownRenderer>{prefix}</original.UnsegmentedMarkdownRenderer>
</UiLocaleProvider>))
void originalHost.offsetHeight
} else {
@@ -119,6 +119,48 @@ async function run() {
flushSync(() => originalRoot.unmount())
originalHost.remove()
}
// Streaming shape: small deltas appended to an already long answer, on
// persistent roots, as ChatTimeline receives them. Alternate variant order.
const deltaSamples = { original: [] as number[], current: [] as number[] }
const deltaHost = document.createElement('div')
deltaHost.className = host.className
deltaHost.style.cssText = host.style.cssText
document.body.append(deltaHost)
const deltaRoot = createRoot(deltaHost)
const deltaStart = Math.floor(longAnswerCorpus.length * 0.8)
const deltaSize = 120
try {
const renderDelta = (variant: 'original' | 'current', prefix: string) => {
if (variant === 'original') {
flushSync(() => deltaRoot.render(<UiLocaleProvider initialPreference="en-US">
<original.UnsegmentedMarkdownRenderer>{prefix}</original.UnsegmentedMarkdownRenderer>
</UiLocaleProvider>))
void deltaHost.offsetHeight
} else {
render(current.MarkdownRenderer, prefix)
void host.offsetHeight
}
}
renderDelta('original', longAnswerCorpus.slice(0, deltaStart))
renderDelta('current', longAnswerCorpus.slice(0, deltaStart))
for (let step = 1; step <= 60; step++) {
const prefix = longAnswerCorpus.slice(0, deltaStart + step * deltaSize)
const order = step % 2 === 0 ? ['original', 'current'] as const : ['current', 'original'] as const
for (const variant of order) {
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
const start = performance.now()
renderDelta(variant, prefix)
if (step > 5) deltaSamples[variant].push(performance.now() - start)
}
if (step % 10 === 0 && snapshot() !== snapshot(deltaHost)) throw new Error(`Delta DOM mismatch: ${step}`)
}
} finally {
flushSync(() => deltaRoot.unmount())
deltaHost.remove()
}
const streamingDelta = { startCharacters: deltaStart, deltaCharacters: deltaSize,
samplesPerVariant: deltaSamples.current.length,
wholeDocumentMs: stats(deltaSamples.original), segmentedMs: stats(deltaSamples.current) }
const longAnswer = { characters: longAnswerCorpus.length, bytes: new TextEncoder().encode(longAnswerCorpus).length,
prefixLengths, warmupSweeps: 1, measuredSweeps: 3, samplesPerVariant: longSamples.current.length,
originalRenderMs: stats(longSamples.original), currentRenderMs: stats(longSamples.current),
@@ -131,7 +173,7 @@ async function run() {
if (normalize(prefix) !== originalNormalize(prefix)) throw new Error(`Normalizer mismatch: ${JSON.stringify(prefix)}`)
prefixesChecked++
for (const renderHtml of [false, true]) {
render(original.MarkdownRenderer, prefix, renderHtml)
render(original.UnsegmentedMarkdownRenderer, prefix, renderHtml)
const expected = snapshot()
render(current.MarkdownRenderer, prefix, renderHtml)
if (snapshot() !== expected) throw new Error(`DOM mismatch (${renderHtml}): ${JSON.stringify(prefix)}`)
@@ -143,7 +185,7 @@ async function run() {
if (!host.querySelector('h1') || !host.querySelector('strong') || !host.querySelector('.katex')) {
throw new Error('Real Markdown formatting missing')
}
return { results, longAnswer, prefixesChecked, domChecks }
return { results, longAnswer, streamingDelta, prefixesChecked, domChecks }
}
Object.assign(window, { streamingMarkdown: run })
@@ -65,6 +65,50 @@ app.whenReady().then(async () => {
for (const locale of ['zh-CN', 'en-US']) {
for (const theme of ['light', 'dark']) {
win.setContentSize(1000, 720)
if (process.env.GB_LAYOUT_MODE === 'source') {
await win.loadURL(`${process.env.GB_LAYOUT_URL}?surface=settings&locale=${locale}&theme=${theme}`)
await wait('!!document.querySelector(".model-download-source-control small") && !document.querySelector(".model-download-source-control [role=status]")')
await js('document.fonts.ready')
for (const [width, height] of [[640, 420], [960, 720], [1280, 800]]) {
win.setContentSize(width, height)
await wait(`innerWidth === ${width} && innerHeight === ${height}`)
await js("document.querySelector('.settings-panel__content').scrollTop=0")
await settle()
const layout = await js(`(() => {
const row=document.querySelector('.model-connections-navigation'),tabs=row.querySelector('.segmented-control'),button=row.querySelector('.model-download-source-control button');
const r=row.getBoundingClientRect(),t=tabs.getBoundingClientRect(),b=button.getBoundingClientRect();
return {rightAligned:Math.abs(r.right-b.right)<1,sameRow:Math.abs(t.y+t.height/2-b.y-b.height/2)<1,
noOverlap:t.right<=b.left,overflow:row.scrollWidth>row.clientWidth+1,hit:button.contains(document.elementFromPoint(b.x+b.width/2,b.y+b.height/2))};
})()`)
assert.deepEqual(layout, {rightAligned:true,sameRow:true,noOverlap:true,overflow:false,hit:true}, JSON.stringify({locale,theme,width,layout}))
const trigger = '.model-download-source-control button'
const dialog = '[aria-labelledby="model-download-source-title"]'
await click(trigger)
await wait(`!!document.querySelector('${dialog}') && document.activeElement.matches('${dialog} .icon-button')`)
await click(`${dialog} input[value="hugging-face"]`)
assert.equal(await js('window.sourceFixture.writes.length'), 0)
await click(`${dialog} .custom-task-dialog__actions .secondary-button`)
await wait(`!document.querySelector('${dialog}') && document.activeElement.matches('${trigger}')`)
assert.equal(await js('window.sourceFixture.source'), 'modelscope')
await click(trigger)
await wait(`!!document.querySelector('${dialog} input[value="modelscope"]:checked')`)
await click(`${dialog} input[value="hugging-face"]`)
await click(`${dialog} .custom-task-dialog__actions .primary-button`)
await wait(`!document.querySelector('${dialog}') && document.activeElement.matches('${trigger}') && document.querySelector('.model-download-source-control small').textContent.includes('Hugging Face')`)
assert.deepEqual(await js('window.sourceFixture.writes'), [{modelDownloadSource:'hugging-face'}])
await click(trigger)
await wait(`!!document.querySelector('${dialog} input[value="hugging-face"]:checked')`)
await key('Escape')
await wait(`!document.querySelector('${dialog}') && document.activeElement.matches('${trigger}')`)
await screenshot(`${locale}-${theme}-source-${width}`)
observations.push({locale,theme,width,height,layout,dialog:'native selection, cancel without write, confirm, reopen, Escape and focus restoration'})
// Reload the fixture to reset its in-memory settings and write log.
await win.loadURL(`${process.env.GB_LAYOUT_URL}?surface=settings&locale=${locale}&theme=${theme}`)
await wait('!!document.querySelector(".model-download-source-control small") && !document.querySelector(".model-download-source-control [role=status]")')
await js('document.fonts.ready')
}
continue
}
await win.loadURL(`${process.env.GB_LAYOUT_URL}?locale=${locale}&theme=${theme}`)
await wait('!!document.querySelector(".workbar-shell__scroll-button")')
await js('document.fonts.ready')
@@ -144,12 +188,13 @@ app.whenReady().then(async () => {
return {containerWidth:manager.clientWidth,stacked:detail.top>=list.bottom-1,selectWidth:e.clientWidth,
textWidth:c.measureText(e.selectedOptions[0].textContent).width,available:e.clientWidth-parseFloat(s.paddingLeft)-parseFloat(s.paddingRight)-20,
overflow:['.settings-panel__content','.model-connection-manager','.model-connection-detail'].filter(sel=>{const n=document.querySelector(sel);return n.scrollWidth>n.clientWidth+1}),
hit:e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2)),value:e.value};
hit:e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2)),value:e.value,
bounds:r.toJSON(),cover:document.elementFromPoint(r.x+r.width/2,r.y+r.height/2)?.outerHTML.slice(0,500)};
})()`)
assert.equal(result.stacked, result.containerWidth <= 640, JSON.stringify(result))
assert.deepEqual(result.overflow, [], JSON.stringify(result))
assert(result.textWidth <= result.available, 'Protocol truncated: ' + JSON.stringify(result))
assert(result.hit, 'Protocol is occluded')
assert(result.hit, 'Protocol is occluded: ' + JSON.stringify({width,height,...result}))
await js("document.querySelector('.model-connection-detail select').closest('.field').previousElementSibling.querySelector('input').focus()")
await key('Tab')
await wait('document.activeElement.matches(".model-connection-detail select")')
+12
View File
@@ -8,6 +8,7 @@ import { applyAppearanceTheme } from '../../src/renderer/src/theme'
import { installBundledUiFonts } from '../../src/renderer/src/fonts'
import { defaultRuntimeSettings, type DesktopApi } from '../../src/shared/contracts'
import type { WorkbarTabInstance } from '../../src/shared/workbar-contracts'
import type { ApplicationSettingsUpdate, ModelDownloadSource } from '../../src/shared/application-settings-contracts'
import '../../src/renderer/src/styles.css'
const query = new URLSearchParams(location.search)
@@ -16,7 +17,18 @@ const longName = locale === 'en-US'
? 'Organization shared model connection for document processing and analysis'
: '\u7ec4\u7ec7\u5185\u90e8\u6587\u6863\u5904\u7406\u4e0e\u5206\u6790\u4e13\u7528\u5171\u4eab\u6a21\u578b\u8fde\u63a5'
const profileId = '10000000-0000-4000-8000-000000000001'
const sourceFixture = { source: 'modelscope' as ModelDownloadSource, writes: [] as ApplicationSettingsUpdate[] }
Object.defineProperty(window, 'sourceFixture', { value: sourceFixture })
Object.defineProperty(window, 'goodbuddy', { value: {
updates: {
getSettings: async () => ({ modelDownloadSource: sourceFixture.source }),
onSettingsChanged: () => () => {},
updateSettings: async (input: ApplicationSettingsUpdate) => {
sourceFixture.writes.push(input)
if (input.modelDownloadSource) sourceFixture.source = input.modelDownloadSource
return { modelDownloadSource: sourceFixture.source }
}
},
settings: {
getRuntime: async () => ({ ...defaultRuntimeSettings, modelProfiles: [{
id: profileId, name: longName, baseUrl: 'http://fixture.invalid/v1', modelName: 'fixture-model',