mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
fix(dashboard): stabilize preview terminal fitting
This commit is contained in:
@@ -129,7 +129,8 @@ export function AgentTerminalPreview({
|
||||
const gridClaim = createPreviewGridClaim({
|
||||
ptyId,
|
||||
container,
|
||||
getTerminal: () => terminal
|
||||
getTerminal: () => terminal,
|
||||
getBaseFontSize: () => settingsRef.current?.terminalFontSize ?? 14
|
||||
})
|
||||
// Box growth/shrink (window resize) changes the reachable grid.
|
||||
const boxResizeObserver =
|
||||
|
||||
@@ -30,7 +30,8 @@ describe('createPreviewGridClaim', () => {
|
||||
const claim = createPreviewGridClaim({
|
||||
ptyId: 'pty-1',
|
||||
container,
|
||||
getTerminal: () => ({ cols: 80, rows: 24 }) as never
|
||||
getTerminal: () => ({ cols: 80, rows: 24, options: { fontSize: 14 } }) as never,
|
||||
getBaseFontSize: () => 14
|
||||
})
|
||||
|
||||
claim.schedule()
|
||||
@@ -63,7 +64,8 @@ describe('createPreviewGridClaim', () => {
|
||||
const claim = createPreviewGridClaim({
|
||||
ptyId: 'pty-1',
|
||||
container,
|
||||
getTerminal: () => ({ cols: 80, rows: 24 }) as never
|
||||
getTerminal: () => ({ cols: 80, rows: 24, options: { fontSize: 14 } }) as never,
|
||||
getBaseFontSize: () => 14
|
||||
})
|
||||
|
||||
claim.schedule()
|
||||
@@ -79,4 +81,33 @@ describe('createPreviewGridClaim', () => {
|
||||
expect(fit).toHaveBeenCalledWith('pty-1', 100, 30)
|
||||
claim.dispose()
|
||||
})
|
||||
|
||||
it('claims from base metrics after fallback fitting shrinks the preview font', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fit = vi.fn(async (_ptyId: string, cols: number, rows: number) => ({ cols, rows }))
|
||||
Object.assign(window, { api: { terminalPreview: { fit } } })
|
||||
const box = document.createElement('div')
|
||||
const container = document.createElement('div')
|
||||
const screen = document.createElement('div')
|
||||
screen.className = 'xterm-screen'
|
||||
box.appendChild(container)
|
||||
container.appendChild(screen)
|
||||
dimension(box, 'clientWidth', 800)
|
||||
dimension(box, 'clientHeight', 480)
|
||||
// The 200x24 source grid was 1600x384 at 14px, then fitted to 800x192 at 7px.
|
||||
dimension(screen, 'offsetWidth', 800)
|
||||
dimension(screen, 'offsetHeight', 192)
|
||||
const claim = createPreviewGridClaim({
|
||||
ptyId: 'pty-1',
|
||||
container,
|
||||
getTerminal: () => ({ cols: 200, rows: 24, options: { fontSize: 7 } }) as never,
|
||||
getBaseFontSize: () => 14
|
||||
})
|
||||
|
||||
claim.schedule()
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
|
||||
expect(fit).toHaveBeenCalledWith('pty-1', 100, 30)
|
||||
claim.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ export function createPreviewGridClaim(args: {
|
||||
ptyId: string
|
||||
container: HTMLElement
|
||||
getTerminal: () => Terminal | null
|
||||
getBaseFontSize: () => number
|
||||
}): { schedule: () => void; dispose: () => void } {
|
||||
let lastRequestedFit: string | null = null
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -40,8 +41,12 @@ export function createPreviewGridClaim(args: {
|
||||
return
|
||||
}
|
||||
// offsetWidth/Height are layout dims, unaffected by the scale transform.
|
||||
const cellWidth = screen.offsetWidth / Math.max(1, terminal.cols)
|
||||
const cellHeight = screen.offsetHeight / Math.max(1, terminal.rows)
|
||||
// Font fitting must not make a later claim ask for the oversized source
|
||||
// grid again; normalize the rendered cells back to settings font metrics.
|
||||
const currentFontSize = terminal.options.fontSize ?? args.getBaseFontSize()
|
||||
const fontScale = args.getBaseFontSize() / currentFontSize
|
||||
const cellWidth = (screen.offsetWidth / Math.max(1, terminal.cols)) * fontScale
|
||||
const cellHeight = (screen.offsetHeight / Math.max(1, terminal.rows)) * fontScale
|
||||
if (
|
||||
!Number.isFinite(cellWidth) ||
|
||||
!Number.isFinite(cellHeight) ||
|
||||
|
||||
@@ -39,6 +39,55 @@ describe('planPreviewFitScale', () => {
|
||||
expect(plan.residualScale).toBeLessThan(1)
|
||||
})
|
||||
|
||||
it('rounds down when the nearest step would exceed the residual blur budget', () => {
|
||||
const plan = planPreviewFitScale({
|
||||
boxWidth: 518,
|
||||
screenWidth: 1000,
|
||||
currentFontSize: 14,
|
||||
baseFontSize: 14
|
||||
})
|
||||
// ideal 7.252; rounding to 7.5 would require scale(0.967), so use 7px natively.
|
||||
expect(plan.fontSize).toBe(7)
|
||||
expect(plan.residualScale).toBe(1)
|
||||
})
|
||||
|
||||
it('converges instead of alternating across rounded xterm measurements', () => {
|
||||
let fontSize = 14
|
||||
const plannedSizes: number[] = []
|
||||
// These are the observed 84-column widths that previously produced the
|
||||
// unbounded 14 -> 6 -> 6.5 -> 6 cycle in a 315px box.
|
||||
const measuredWidths = new Map([
|
||||
[14, 706],
|
||||
[6, 302],
|
||||
[6.5, 328]
|
||||
])
|
||||
for (let step = 0; step < 5; step += 1) {
|
||||
const plan = planPreviewFitScale({
|
||||
boxWidth: 315,
|
||||
screenWidth: measuredWidths.get(fontSize)!,
|
||||
currentFontSize: fontSize,
|
||||
baseFontSize: 14,
|
||||
maxFontSize: step === 0 ? 14 : fontSize
|
||||
})
|
||||
fontSize = plan.fontSize
|
||||
plannedSizes.push(fontSize)
|
||||
}
|
||||
|
||||
expect(plannedSizes).toEqual([6, 6, 6, 6, 6])
|
||||
})
|
||||
|
||||
it('holds a same-layout font when residual scaling alone suggests the adjacent step', () => {
|
||||
const plan = planPreviewFitScale({
|
||||
boxWidth: 372,
|
||||
screenWidth: 354,
|
||||
currentFontSize: 7,
|
||||
baseFontSize: 14,
|
||||
maxFontSize: 7
|
||||
})
|
||||
expect(plan.fontSize).toBe(7)
|
||||
expect(plan.residualScale).toBe(1)
|
||||
})
|
||||
|
||||
it('never scales the font above the settings base when the box grows back', () => {
|
||||
const plan = planPreviewFitScale({
|
||||
boxWidth: 2000,
|
||||
@@ -50,6 +99,26 @@ describe('planPreviewFitScale', () => {
|
||||
expect(plan.residualScale).toBe(1)
|
||||
})
|
||||
|
||||
it('restores the base font after the preview grid claim succeeds', () => {
|
||||
const fallback = planPreviewFitScale({
|
||||
boxWidth: 800,
|
||||
screenWidth: 1600,
|
||||
currentFontSize: 14,
|
||||
baseFontSize: 14
|
||||
})
|
||||
expect(fallback.fontSize).toBe(7)
|
||||
|
||||
// The accepted claim halves the grid, so its 7px rendering is now 400px wide.
|
||||
const claimed = planPreviewFitScale({
|
||||
boxWidth: 800,
|
||||
screenWidth: 400,
|
||||
currentFontSize: fallback.fontSize,
|
||||
baseFontSize: 14
|
||||
})
|
||||
expect(claimed.fontSize).toBe(14)
|
||||
expect(claimed.residualScale).toBe(1)
|
||||
})
|
||||
|
||||
it('holds steady within tolerance so measure-apply loops cannot oscillate', () => {
|
||||
// After the first shrink lands, the re-measure produces a near-identical
|
||||
// ideal; a repeated plan must return the current font unchanged.
|
||||
|
||||
@@ -28,8 +28,10 @@ export function planPreviewFitScale(args: {
|
||||
currentFontSize: number
|
||||
/** The settings-configured font size the preview must never exceed. */
|
||||
baseFontSize: number
|
||||
/** Same-layout remeasurement caps growth to prevent adjacent-step cycles. */
|
||||
maxFontSize?: number
|
||||
}): PreviewFitScalePlan {
|
||||
const { boxWidth, screenWidth, currentFontSize, baseFontSize } = args
|
||||
const { boxWidth, screenWidth, currentFontSize, baseFontSize, maxFontSize = baseFontSize } = args
|
||||
if (boxWidth <= 0 || screenWidth <= 0 || currentFontSize <= 0 || baseFontSize <= 0) {
|
||||
return { fontSize: currentFontSize > 0 ? currentFontSize : baseFontSize, residualScale: 1 }
|
||||
}
|
||||
@@ -37,7 +39,20 @@ export function planPreviewFitScale(args: {
|
||||
// within the tolerance and the residual transform absorbs rounding.
|
||||
const naturalWidthAtBase = (screenWidth / currentFontSize) * baseFontSize
|
||||
const idealFontSize = baseFontSize * (boxWidth / naturalWidthAtBase)
|
||||
const fontSize = Math.round(clamp(idealFontSize, MIN_PREVIEW_FONT_PX, baseFontSize) * 2) / 2
|
||||
const clampedFontSize = clamp(
|
||||
idealFontSize,
|
||||
MIN_PREVIEW_FONT_PX,
|
||||
Math.min(baseFontSize, maxFontSize)
|
||||
)
|
||||
const nearestFontSize = Math.round(clampedFontSize * 2) / 2
|
||||
const nearestPredictedWidth = (screenWidth / currentFontSize) * nearestFontSize
|
||||
const nearestResidualScale = Math.min(1, boxWidth / nearestPredictedWidth)
|
||||
// Prefer the next smaller native step when nearest rounding would leave a
|
||||
// visible transform; below the readability floor, the fallback is accepted.
|
||||
const fontSize =
|
||||
clampedFontSize > MIN_PREVIEW_FONT_PX && nearestResidualScale < 1 - FONT_FIT_TOLERANCE
|
||||
? Math.floor(clampedFontSize * 2) / 2
|
||||
: nearestFontSize
|
||||
const withinTolerance =
|
||||
Math.abs(fontSize - currentFontSize) / currentFontSize <= FONT_FIT_TOLERANCE
|
||||
const effectiveFontSize = withinTolerance ? currentFontSize : fontSize
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPreviewFitScheduler } from './preview-terminal-fit'
|
||||
|
||||
function dimension(element: HTMLElement, name: string, get: () => number): void {
|
||||
Object.defineProperty(element, name, { configurable: true, get })
|
||||
}
|
||||
|
||||
describe('createPreviewFitScheduler', () => {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('settles the reported adjacent-size cycle without another frame', () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
const box = document.createElement('div')
|
||||
const container = document.createElement('div')
|
||||
const screen = document.createElement('div')
|
||||
screen.className = 'xterm-screen'
|
||||
box.appendChild(container)
|
||||
container.appendChild(screen)
|
||||
const terminal = {
|
||||
cols: 84,
|
||||
rows: 24,
|
||||
options: { fontSize: 14 },
|
||||
buffer: { active: { cursorY: 0 } }
|
||||
}
|
||||
const measuredWidths = new Map([
|
||||
[14, 706],
|
||||
[6, 302],
|
||||
[6.5, 328]
|
||||
])
|
||||
dimension(box, 'clientWidth', () => 315)
|
||||
dimension(box, 'clientHeight', () => 480)
|
||||
dimension(screen, 'offsetWidth', () => measuredWidths.get(terminal.options.fontSize)!)
|
||||
dimension(screen, 'offsetHeight', () => 384)
|
||||
const { scheduleFit } = createPreviewFitScheduler({
|
||||
container,
|
||||
getTerminal: () => terminal as never,
|
||||
getBaseFontSize: () => 14
|
||||
})
|
||||
|
||||
scheduleFit()
|
||||
frames.shift()!(0)
|
||||
expect(terminal.options.fontSize).toBe(6)
|
||||
expect(frames).toHaveLength(1)
|
||||
|
||||
frames.shift()!(0)
|
||||
expect(terminal.options.fontSize).toBe(6)
|
||||
expect(frames).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('allows the base font to return after a successful grid claim', () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
const box = document.createElement('div')
|
||||
const container = document.createElement('div')
|
||||
const screen = document.createElement('div')
|
||||
screen.className = 'xterm-screen'
|
||||
box.appendChild(container)
|
||||
container.appendChild(screen)
|
||||
const terminal = {
|
||||
cols: 200,
|
||||
rows: 24,
|
||||
options: { fontSize: 7 },
|
||||
buffer: { active: { cursorY: 0 } }
|
||||
}
|
||||
dimension(box, 'clientWidth', () => 800)
|
||||
dimension(box, 'clientHeight', () => 480)
|
||||
dimension(
|
||||
screen,
|
||||
'offsetWidth',
|
||||
() => (terminal.cols === 200 ? 800 : 400) * (terminal.options.fontSize / 7)
|
||||
)
|
||||
dimension(screen, 'offsetHeight', () => 192)
|
||||
const { scheduleFit } = createPreviewFitScheduler({
|
||||
container,
|
||||
getTerminal: () => terminal as never,
|
||||
getBaseFontSize: () => 14
|
||||
})
|
||||
|
||||
scheduleFit()
|
||||
frames.shift()!(0)
|
||||
expect(terminal.options.fontSize).toBe(7)
|
||||
|
||||
terminal.cols = 100
|
||||
scheduleFit()
|
||||
frames.shift()!(0)
|
||||
expect(terminal.options.fontSize).toBe(14)
|
||||
expect(frames).toHaveLength(1)
|
||||
|
||||
frames.shift()!(0)
|
||||
expect(terminal.options.fontSize).toBe(14)
|
||||
expect(frames).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ export function createPreviewFitScheduler(args: {
|
||||
getBaseFontSize: () => number
|
||||
}): { scheduleFit: () => void } {
|
||||
const { container, getTerminal, getBaseFontSize } = args
|
||||
let lastFitContext: { boxWidth: number; cols: number; baseFontSize: number } | null = null
|
||||
|
||||
const fitToBox = (): void => {
|
||||
const terminal = getTerminal()
|
||||
@@ -22,12 +23,20 @@ export function createPreviewFitScheduler(args: {
|
||||
return
|
||||
}
|
||||
const baseFontSize = getBaseFontSize()
|
||||
const currentFontSize = terminal.options.fontSize ?? baseFontSize
|
||||
const allowFontGrowth =
|
||||
!lastFitContext ||
|
||||
box.clientWidth > lastFitContext.boxWidth ||
|
||||
terminal.cols !== lastFitContext.cols ||
|
||||
baseFontSize !== lastFitContext.baseFontSize
|
||||
const plan = planPreviewFitScale({
|
||||
boxWidth: box.clientWidth,
|
||||
screenWidth: Math.max(1, screen.offsetWidth),
|
||||
currentFontSize: terminal.options.fontSize ?? baseFontSize,
|
||||
baseFontSize
|
||||
currentFontSize,
|
||||
baseFontSize,
|
||||
maxFontSize: allowFontGrowth ? baseFontSize : currentFontSize
|
||||
})
|
||||
lastFitContext = { boxWidth: box.clientWidth, cols: terminal.cols, baseFontSize }
|
||||
if (plan.fontSize !== terminal.options.fontSize) {
|
||||
terminal.options.fontSize = plan.fontSize
|
||||
// Re-measure next frame: the renderer applies the new metrics async.
|
||||
|
||||
Reference in New Issue
Block a user