diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27aba30..6800f6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,6 +135,12 @@ jobs: ELECTRON_DISABLE_SANDBOX: '1' run: xvfb-run --auto-servernum npm run perf:canvas + - name: Canvas animation clock and streaming cadence + if: runner.os == 'Linux' + env: + ELECTRON_DISABLE_SANDBOX: '1' + run: xvfb-run --auto-servernum npm run smoke:animation + - name: Multi-repo suite (composite worktrees, real git) run: npm run smoke:multirepo diff --git a/package.json b/package.json index d6cfb9a..d52fc1f 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "i18n:translate": "node script/i18n-translate.mjs", "canvas": "vite --config test/canvas/vite.config.mjs", "smoke:canvas": "electron test/canvas/smoke.cjs", + "smoke:animation": "electron test/canvas/animation.cjs", "perf:canvas": "electron test/canvas/performance.cjs", "smoke:diff": "esbuild test/canvas/diff.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/diff.cjs && node test/.out/diff.cjs" }, diff --git a/src/main/db/repo.ts b/src/main/db/repo.ts index 0b0f9be..c8af39d 100644 --- a/src/main/db/repo.ts +++ b/src/main/db/repo.ts @@ -4,6 +4,7 @@ import { normalizeServerConfig, type McpServerConfig, type McpServerRecord } fro import { DEFAULT_BRANCH_PREFIX, normalizeBranchPrefix } from '../../shared/branch' import { DEFAULT_LANGUAGE, normalizeLanguage } from '../../shared/i18n' import type { Language } from '../../shared/i18n' +import { DEFAULT_MOTION, normalizeMotion, type MotionPreference } from '../../shared/motion' import type { AddMessageInput, AppSettings, @@ -125,6 +126,7 @@ export function getSettings(): AppSettings { // (or a language later dropped from the app) must degrade to English rather // than leave the UI rendering raw keys. language: normalizeLanguage(map.get('language')), + motion: normalizeMotion(map.get('motion')), activeThemeId: map.get('active_theme_id') ?? null } } @@ -226,6 +228,12 @@ export function setActiveThemeId(id: string | null): AppSettings { return getSettings() } +export function setMotion(value: MotionPreference): AppSettings { + const motion = normalizeMotion(value) + setSetting('motion', motion === DEFAULT_MOTION ? null : motion) + return getSettings() +} + export function setAutoWorkstream(enabled: boolean): AppSettings { // Store only the OFF state; see getSettings for why. setSetting('auto_workstream', enabled ? null : '0') diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 4116a02..a2111f7 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -1,6 +1,7 @@ import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron' import { CHANNELS } from '../../shared/ipc' import type { Language } from '../../shared/i18n' +import { DEFAULT_MOTION, type MotionPreference } from '../../shared/motion' import type { SessionConfigPatch } from '../../shared/session-config' import type { ClipboardAction } from '../../shared/context-menu' import { clipboardHasContent, runClipboardAction } from '../services/context-menu' @@ -207,6 +208,13 @@ export function registerIpc(): void { ipcMain.handle(CHANNELS.settingsSetLanguage, (_e, language: Language) => repo.setLanguage(language) ) + ipcMain.handle(CHANNELS.settingsSetMotion, (_e, motion: MotionPreference) => { + const settings = repo.setMotion(motion) + for (const window of BrowserWindow.getAllWindows()) + if (!window.isDestroyed()) + window.webContents.send(CHANNELS.settingsMotionChanged, settings.motion) + return settings + }) ipcMain.handle(CHANNELS.settingsSetBranchPrefix, (_e, prefix: string) => repo.setBranchPrefix(prefix) ) @@ -220,7 +228,10 @@ export function registerIpc(): void { for (const id of CLIPROXY_PROVIDER_IDS) { await cliproxy.disconnect(id).catch(() => undefined) } - return repo.resetAll() + repo.resetAll() + for (const window of BrowserWindow.getAllWindows()) + if (!window.isDestroyed()) + window.webContents.send(CHANNELS.settingsMotionChanged, DEFAULT_MOTION) }) // Telemetry lives outside the settings table (see services/track), so it gets diff --git a/src/preload/index.ts b/src/preload/index.ts index c671b0b..9637d3f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -15,6 +15,7 @@ import type { } from '../shared/api' import type { CliProxyState } from '../shared/cliproxy' import type { ResolvedTheme } from '../shared/theme' +import type { MotionPreference } from '../shared/motion' /** * The typed bridge exposed to the renderer as `window.roxy`. Every method maps @@ -31,6 +32,13 @@ const roxy: RoxyApi = { setAutoWorkstream: (enabled) => ipcRenderer.invoke(CHANNELS.settingsSetAutoWorkstream, enabled), setBranchPrefix: (prefix) => ipcRenderer.invoke(CHANNELS.settingsSetBranchPrefix, prefix), setLanguage: (language) => ipcRenderer.invoke(CHANNELS.settingsSetLanguage, language), + setMotion: (motion) => ipcRenderer.invoke(CHANNELS.settingsSetMotion, motion), + onMotionChanged: (callback) => { + const handler = (_event: Electron.IpcRendererEvent, motion: MotionPreference): void => + callback(motion) + ipcRenderer.on(CHANNELS.settingsMotionChanged, handler) + return () => ipcRenderer.removeListener(CHANNELS.settingsMotionChanged, handler) + }, completeOnboarding: () => ipcRenderer.invoke(CHANNELS.settingsCompleteOnboarding), reset: () => ipcRenderer.invoke(CHANNELS.settingsReset), getTelemetry: () => ipcRenderer.invoke(CHANNELS.settingsGetTelemetry), diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 0d7aa1d..0c9df06 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -176,22 +176,23 @@ transform: scale(0.96); } -/* Respect reduced-motion: keep the opacity fade (it aids comprehension) but drop - the scale/translate movement and the press squish. */ -@media (prefers-reduced-motion: reduce) { - :root { - --animate-pop-in: fade-in 120ms ease both; - --animate-fade-in: fade-in 120ms ease both; - } - .animate-modal-in { - animation: modal-scrim-in 140ms ease both; - } - .press-scale:active:not(:disabled) { - transform: none; - } - .animate-ticker-in { - animation: fade-in 120ms ease both; - } +/* One resolved app preference drives CSS and canvas alike. */ +:root[data-motion='reduced'] { + --animate-pop-in: fade-in 120ms ease both; + --animate-fade-in: fade-in 120ms ease both; +} +[data-motion='reduced'] .animate-modal-in { + animation: modal-scrim-in 140ms ease both; +} +[data-motion='reduced'] .press-scale:active:not(:disabled) { + transform: none; +} +[data-motion='reduced'] .animate-ticker-in { + animation: fade-in 120ms ease both; +} +[data-motion='reduced'] .animate-spin, +[data-motion='reduced'] .animate-ping { + animation: pulse 2s ease-in-out infinite; } /* On macOS, prefer the native system font (San Francisco) over Geist so the app diff --git a/src/renderer/src/browser/main.tsx b/src/renderer/src/browser/main.tsx index 817993d..5d28f4a 100644 --- a/src/renderer/src/browser/main.tsx +++ b/src/renderer/src/browser/main.tsx @@ -11,6 +11,7 @@ import ReactDOM from 'react-dom/client' import { BrowserChrome } from './BrowserChrome' import { AppContextMenu } from '../components/AppContextMenu' import { primeTheme, startTheme } from '../lib/theme' +import { startMotion } from '../lib/motion' // Reserve space for the native window-control overlay (same as the main window). document.documentElement.dataset.platform = window.electron?.process?.platform ?? 'win32' @@ -20,6 +21,7 @@ document.documentElement.dataset.platform = window.electron?.process?.platform ? // broadcast from main rather than reading a value once at launch. primeTheme() startTheme() +startMotion() // Best-effort: paint in English if the settings read fails rather than blocking // the toolbar on it. diff --git a/src/renderer/src/canvas/CanvasSurface.tsx b/src/renderer/src/canvas/CanvasSurface.tsx index 86e9deb..3e2f5b9 100644 --- a/src/renderer/src/canvas/CanvasSurface.tsx +++ b/src/renderer/src/canvas/CanvasSurface.tsx @@ -24,6 +24,7 @@ import { import { CanvasMenu, type CanvasMenuItem } from './CanvasMenu' import { openLink } from './links' import { diffPatch } from '../components/diff/model' +import { prefersReducedMotion, subscribeMotion } from '../lib/motion' import { PromptHistoryRail } from './PromptHistoryRail' import { activePrompt, PROMPT_OFFSET, type PromptAnchor, type PromptEntry } from './prompt-history' @@ -130,6 +131,7 @@ export function CanvasSurface({ const drawRef = useRef<(position?: boolean) => void>(() => {}) const layoutRef = useRef<(targetId?: string) => void>(() => {}) const mounted = useRef(false) + const reducedMotion = useRef(false) const syntaxPending = useRef(new WeakSet()) const debug = useRef({ frames: 0, @@ -241,12 +243,26 @@ export function CanvasSurface({ useLayoutEffect(() => { mounted.current = true + reducedMotion.current = prefersReducedMotion() + const resume = (): void => { + cancelAnimationFrame(frame.current) + frame.current = 0 + if (!document.hidden) requestPaint() + } + const motionChanged = (): void => { + reducedMotion.current = prefersReducedMotion() + resume() + } + const stopMotion = subscribeMotion(motionChanged) + document.addEventListener('visibilitychange', resume) return () => { mounted.current = false + stopMotion() + document.removeEventListener('visibilitychange', resume) cancelAnimationFrame(frame.current) frame.current = 0 } - }, []) + }, [requestPaint]) useLayoutEffect(() => { const draw = (position = false): void => { @@ -343,7 +359,8 @@ export function CanvasSurface({ metrics, scrollTop: el.scrollTop, viewportHeight: size.height, - now: matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : performance.now(), + now: performance.now(), + reducedMotion: reducedMotion.current, hovered: hovered.current, selection: selection.current, images: view.current.images @@ -352,11 +369,8 @@ export function CanvasSurface({ debug.current.firstPaint.at = performance.now() if (import.meta.env.DEV) debug.current.paintMs += performance.now() - paintStarted debug.current.frames++ - if ( - hasAnimation(scene.current, el.scrollTop, size.height) && - !matchMedia('(prefers-reduced-motion: reduce)').matches - ) - requestPaint() + // Reduced motion removes rotation, not the essential indication that work is continuing. + if (!document.hidden && hasAnimation(scene.current, el.scrollTop, size.height)) requestPaint() } drawRef.current = draw const layout = (targetId?: string): void => { diff --git a/src/renderer/src/canvas/PromptHistoryRail.tsx b/src/renderer/src/canvas/PromptHistoryRail.tsx index 9e81db6..9152500 100644 --- a/src/renderer/src/canvas/PromptHistoryRail.tsx +++ b/src/renderer/src/canvas/PromptHistoryRail.tsx @@ -149,7 +149,7 @@ export function PromptHistoryRail({ observer.disconnect() window.removeEventListener('resize', close) } - }, [item, close, height, scrollTop, i18n.language]) + }, [item, close, height, viewport, scrollTop, i18n.language]) if (!entries.length || height < 56) return null diff --git a/src/renderer/src/canvas/ansi.ts b/src/renderer/src/canvas/ansi.ts index 6e99185..d1160c4 100644 --- a/src/renderer/src/canvas/ansi.ts +++ b/src/renderer/src/canvas/ansi.ts @@ -130,24 +130,26 @@ export function parseAnsi(text: string): AnsiSpan[][] { const lines: AnsiSpan[][] = [] let current: AnsiSpan[] = [] let style: AnsiStyle = {} + let carriageReturn = false /** Append a run of plain text, honouring newlines and carriage returns. */ const pushText = (chunk: string): void => { if (chunk === '') return - const rows = chunk.split('\n') - for (let r = 0; r < rows.length; r++) { - if (r > 0) { + for (const part of chunk.split(/([\r\n])/)) { + if (part === '\r') { + // A CR only overwrites when more text follows. CRLF (even across SGR spans) ends the line. + carriageReturn = true + } else if (part === '\n') { lines.push(current) current = [] + carriageReturn = false + } else if (part !== '') { + if (carriageReturn) current = [] + carriageReturn = false + const last = current[current.length - 1] + if (last && sameStyle(last, style)) last.text += part + else current.push({ ...style, text: part }) } - // Everything before the last CR on a row was overwritten in place. - const segments = rows[r].split('\r') - if (segments.length > 1) current = [] - const visible = segments[segments.length - 1] - if (visible === '') continue - const last = current[current.length - 1] - if (last && sameStyle(last, style)) last.text += visible - else current.push({ ...style, text: visible }) } } diff --git a/src/renderer/src/canvas/prompt-history.css b/src/renderer/src/canvas/prompt-history.css index 1d35edc..21237d6 100644 --- a/src/renderer/src/canvas/prompt-history.css +++ b/src/renderer/src/canvas/prompt-history.css @@ -1,6 +1,7 @@ .prompt-history { position: absolute; - top: 16px; + top: 50%; + transform: translateY(-50%); right: 12px; width: 28px; z-index: 20; @@ -133,9 +134,7 @@ font-variant-numeric: tabular-nums; } -@media (prefers-reduced-motion: reduce) { - .prompt-history-marker > span, - .prompt-history-preview { - transition: none; - } +[data-motion='reduced'] .prompt-history-marker > span, +[data-motion='reduced'] .prompt-history-preview { + transition: none; } diff --git a/src/renderer/src/canvas/renderer.ts b/src/renderer/src/canvas/renderer.ts index ff2e081..250503f 100644 --- a/src/renderer/src/canvas/renderer.ts +++ b/src/renderer/src/canvas/renderer.ts @@ -32,6 +32,7 @@ export interface PaintContext { scrollTop: number viewportHeight: number now: number + reducedMotion?: boolean /** The region under the pointer, so it can be washed. */ hovered: HitRegion | null /** Decoded images by src (the transcript keeps the cache). */ @@ -188,14 +189,25 @@ function paintNode(node: Node, paint: PaintContext, pen: Pen): void { return case 'spinner': - drawSpinner(ctx, node.x, node.y, node.size, node.color, paint.now) + ctx.save() + if (paint.reducedMotion) ctx.globalAlpha *= pulseAlpha(paint.now) + drawSpinner(ctx, node.x, node.y, node.size, node.color, paint.reducedMotion ? 0 : paint.now) + ctx.restore() pen.invalidate() return case 'braille': + ctx.save() + if (paint.reducedMotion) ctx.globalAlpha *= pulseAlpha(paint.now) pen.setFont(fontCss(node.font, theme)) pen.setFill(node.color) - ctx.fillText(brailleFrame(paint.now), node.x, node.y + baselineOffset(node.font)) + ctx.fillText( + brailleFrame(paint.reducedMotion ? 0 : paint.now), + node.x, + node.y + baselineOffset(node.font) + ) + ctx.restore() + pen.invalidate() return case 'image': diff --git a/src/renderer/src/canvas/terminal.ts b/src/renderer/src/canvas/terminal.ts new file mode 100644 index 0000000..6d9a586 --- /dev/null +++ b/src/renderer/src/canvas/terminal.ts @@ -0,0 +1,221 @@ +import { + ANSI_BG, + ANSI_DEFAULT_FG, + ANSI_PROMPT, + ansiLineText, + parseAnsi, + type AnsiSpan +} from './ansi' +import type { Builder } from './builder' +import type { MessagePart } from '@shared/types' +import type { ViewState } from './scene' +import { font, type TextMetrics, type TextRun, type WrappedLine } from './text' +import { alpha } from './theme' +import { FONT_SIZE, SIZE, SPACE } from './metrics' + +type ToolPart = Extract +type TerminalLayout = { + width: number + epoch: number + source: string + command: string + emptyLabel: string + lines: WrappedLine[] + output: string + copyCommand: string +} +const layouts = new WeakMap() +const FOOTER = /^\[(exit -?\d+|timed out[\s\S]*|error:[\s\S]*|stopped|cancelled)\]$/ +const FOOTER_COLOR = '#9a9aa3' +const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + +/** Wrap terminal columns without losing whitespace, ANSI styling, or logical copy boundaries. */ +function wrapRows(rows: AnsiSpan[][], width: number, metrics: TextMetrics): WrappedLine[] { + const base = font(FONT_SIZE.small, 400, 'mono') + const height = metrics.lineHeight(base) + const lines: WrappedLine[] = [] + for (const row of rows) { + let runs: TextRun[] = [] + let x = 0 + let offset = 0 + const flush = (breakAfter: boolean): void => { + lines.push({ runs, width: x, y: lines.length * height, height, breakAfter }) + runs = [] + x = 0 + } + for (const span of row) { + const f = font(base.size, span.bold ? 700 : 400, 'mono', span.italic ? 'italic' : 'normal') + const advance = metrics.advance(f) + const color = span.dim + ? alpha(span.color ?? ANSI_DEFAULT_FG, 0.7) + : (span.color ?? ANSI_DEFAULT_FG) + const ascii = /^[\x20-\x7e\t]*$/.test(span.text) + const characters = ascii + ? span.text + : Array.from(graphemes.segment(span.text), (item) => item.segment) + for (const text of characters) { + const tab = text === '\t' + let size = tab + ? (4 - (Math.round(x / advance) % 4)) * advance + : ascii + ? advance + : metrics.measure(text, f) + if (x > 0 && x + size > width + 0.001) { + flush(false) + if (tab) size = 4 * advance + } + if (tab) size = Math.min(size, width) + const last = runs[runs.length - 1] + if (!tab && last?.font === f && last.text !== '\t') { + last.text += text + last.width += size + } else { + runs.push({ + text, + font: f, + color, + x, + width: size, + offset, + background: span.background, + underline: span.underline + }) + } + x += size + offset += text.length + } + } + flush(true) + } + return lines +} + +/** Content-sized shell output; only long logs need an inner vertical viewport. */ +export function layoutTerminalBody( + builder: Builder, + part: ToolPart, + id: string, + view: ViewState, + x: number, + y: number, + width: number +): number { + const source = part.output ?? '' + const command = + part.tool === 'bash' + ? typeof part.input?.command === 'string' + ? part.input.command + : (part.title ?? '') + : '' + const emptyLabel = builder.t( + part.state === 'running' ? 'transcript.running' : 'transcript.noOutput' + ) + let cached = layouts.get(part) + if ( + !cached || + cached.width !== width || + cached.epoch !== builder.theme.epoch || + cached.source !== source || + cached.command !== command || + cached.emptyLabel !== emptyLabel + ) { + let body = source.replace(/\r\n/g, '\n') + let prompt = command ? `$ ${command.replace(/\r\n/g, '\n')}` : '' + let copyCommand = command + if (prompt && (body === prompt || body.startsWith(prompt + '\n'))) { + body = body.slice(prompt.length).replace(/^\n/, '') + } else if (body.startsWith('$ ')) { + const end = body.indexOf('\n') + prompt = end < 0 ? body : body.slice(0, end) + copyCommand = prompt.slice(2) + body = end < 0 ? '' : body.slice(end + 1) + } + const rows = parseAnsi(body) + const trimEmptyTail = (): void => { + while (rows.length && !ansiLineText(rows[rows.length - 1]).trim()) rows.pop() + } + // Measure what is actually visible, not escape-only lines or trailing terminal padding. + trimEmptyTail() + const last = rows.length ? ansiLineText(rows[rows.length - 1]).trim() : '' + if (FOOTER.test(last)) { + rows.pop() + trimEmptyTail() + rows.push([{ text: last, color: FOOTER_COLOR }]) + } + const output = rows.map(ansiLineText).join('\n') + if (prompt) rows.unshift(...prompt.split('\n').map((text) => [{ text, color: ANSI_PROMPT }])) + if (!rows.length) rows.push([{ text: emptyLabel, color: FOOTER_COLOR }]) + const lines = wrapRows(rows, Math.max(1, width - SPACE.bodyPadX * 2), builder.metrics) + cached = { + width, + epoch: builder.theme.epoch, + source, + command, + emptyLabel, + lines, + output, + copyCommand + } + layouts.set(part, cached) + } + + const { lines } = cached + const lineHeight = builder.metrics.lineHeight(font(FONT_SIZE.small, 400, 'mono')) + const contentHeight = lines.length * lineHeight + SPACE.bodyPadY * 2 + const height = Math.min(SIZE.outputMax, contentHeight) + const top = Math.max(0, Math.min(view.scroll.get(id)?.top ?? 0, contentHeight - height)) + view.scroll.set(id, { left: 0, top }) + const copyActions = [{ label: builder.t('transcript.copyOutput'), text: cached.output }] + if (cached.copyCommand) + copyActions.unshift({ label: builder.t('transcript.copyCommand'), text: cached.copyCommand }) + builder.scrollRegion({ + id, + x, + y: y + 1, + w: width, + h: height, + contentWidth: width, + contentHeight, + left: 0, + top, + copyActions + }) + builder.hairline(x, y, width, builder.palette.border) + builder.rect(x, y + 1, width, height, 0, ANSI_BG) + const textY = y + 1 + SPACE.bodyPadY - top + const first = Math.max(0, Math.floor((top - SPACE.bodyPadY) / lineHeight)) + const last = Math.min(lines.length, Math.ceil((top + height - SPACE.bodyPadY) / lineHeight)) + builder.clipped(x, y + 1, width, height, 0, () => { + builder.lines(x + SPACE.bodyPadX, textY, lines.slice(first, last), false) + }) + for (const line of lines) { + builder.selectableRow( + x + SPACE.bodyPadX, + textY + line.y, + line.height, + line.runs, + line.runs.map((run) => run.text).join(''), + { + clip: { + x: x + SPACE.bodyPadX, + y: y + 1, + w: Math.max(1, width - SPACE.bodyPadX * 2), + h: height + }, + breakAfter: line.breakAfter + } + ) + } + if (height < contentHeight) { + const thumb = Math.max(24, (height * height) / contentHeight) + builder.rect( + x + width - 7, + y + 1 + (top / (contentHeight - height)) * (height - thumb), + 4, + thumb, + 2, + FOOTER_COLOR + ) + } + return height + 1 +} diff --git a/src/renderer/src/canvas/tool-card.ts b/src/renderer/src/canvas/tool-card.ts index b823f63..97247bc 100644 --- a/src/renderer/src/canvas/tool-card.ts +++ b/src/renderer/src/canvas/tool-card.ts @@ -29,7 +29,7 @@ import { layoutPlainText } from './prose' import { layoutDiffViewer } from '../components/diff/layout' import { createDiffState } from '../components/diff/model' import { highlight, tokenColors } from './highlight' -import { ANSI_BG, ANSI_DEFAULT_FG, ANSI_PROMPT, parseAnsi } from './ansi' +import { layoutTerminalBody } from './terminal' /** * Tool → icon. Carried over verbatim from the DOM's `TOOL_ICON`, plus entries @@ -161,7 +161,7 @@ export function layoutToolCard( } else if (part.tool === 'read' && part.state === 'done' && body && !part.image) { cursor += layoutFileBody(builder, part.title ?? 'file.txt', body, x + 1, cursor, contentWidth) } else if (part.tool === 'bash' || part.tool === 'bash_output') { - cursor += layoutTerminalBody(builder, body, part.state, x + 1, cursor, contentWidth) + cursor += layoutTerminalBody(builder, part, id, input.view, x + 1, cursor, contentWidth) } else if (showNested) { cursor += layoutNestedBody(builder, input, body, x + 1, cursor, contentWidth) } else { @@ -468,131 +468,6 @@ function layoutFileBody( return 1 + height } -/** - * Shell output as a terminal pane — prompt line, ANSI body, status footer. - * - * Same three-part split as the DOM's TerminalOutput, including the deliberate - * grey footer: `[exit 1]` in an agent's shell is ordinary (a build run to see - * what breaks, a grep that misses), and colouring it red made a normal - * transcript read like a disaster log. - */ -const FOOTER_RE = /^\[(exit \d+|timed out[\s\S]*|error:[\s\S]*)\]$/ -const FOOTER_COLOR = '#9a9aa3' - -function layoutTerminalBody( - builder: Builder, - text: string, - state: 'running' | 'done' | 'error', - x: number, - y: number, - width: number -): number { - const palette = builder.palette - let prompt = '' - let body = text - if (body.startsWith('$ ')) { - const nl = body.indexOf('\n') - prompt = nl === -1 ? body : body.slice(0, nl) - body = nl === -1 ? '' : body.slice(nl + 1) - } - let footer = '' - const split = body.split('\n') - const lastLine = split[split.length - 1] - if (lastLine && FOOTER_RE.test(lastLine)) { - footer = lastLine - body = split.slice(0, -1).join('\n') - } - const trimmed = body.replace(/[\r\n]+$/, '') - - const codeFont = font(FONT_SIZE.small, 400, 'mono') - const lineHeight = builder.metrics.lineHeight(codeFont) - const rows = trimmed === '' ? [] : parseAnsi(trimmed) - const empty = !prompt && !trimmed && !footer - - let lineCount = rows.length - if (prompt) lineCount += 1 - if (footer) lineCount += 1 - if (empty) lineCount = 1 - - const height = Math.min(SIZE.outputMax, lineCount * lineHeight + SPACE.bodyPadY * 2) - - builder.hairline(x, y, width, palette.border) - // The terminal keeps its own near-black background in both appearances: a - // terminal is a terminal, and the ANSI palette is calibrated against dark. - builder.rect(x, y + 1, width, height, 0, ANSI_BG) - - builder.clipped(x, y + 1, width, height, 0, () => { - let cursor = y + 1 + SPACE.bodyPadY - if (prompt) { - builder.push({ - kind: 'text', - x: x + SPACE.bodyPadX, - y: cursor, - text: prompt, - font: codeFont, - color: ANSI_PROMPT - }) - cursor += lineHeight - } - if (rows.length > 0) { - builder.push({ - kind: 'ansi', - x: x + SPACE.bodyPadX, - y: cursor, - w: width - SPACE.bodyPadX * 2, - lineHeight, - font: codeFont, - rows, - defaultColor: ANSI_DEFAULT_FG - }) - cursor += rows.length * lineHeight - } - if (footer) { - builder.push({ - kind: 'text', - x: x + SPACE.bodyPadX, - y: cursor, - text: footer, - font: codeFont, - color: FOOTER_COLOR - }) - } - if (empty) { - builder.push({ - kind: 'text', - x: x + SPACE.bodyPadX, - y: cursor, - text: builder.t(state === 'running' ? 'transcript.running' : 'transcript.noOutput'), - font: codeFont, - color: FOOTER_COLOR - }) - } - }) - - // Selectable rows, capped at what is visible. - const advance = builder.metrics.advance(codeFont) - let selY = y + 1 + SPACE.bodyPadY - const bottom = y + 1 + height - const addRow = (value: string, color: string): void => { - if (selY > bottom) return - builder.selectableRow( - x + SPACE.bodyPadX, - selY, - lineHeight, - [{ text: value, font: codeFont, color, x: 0, width: advance * value.length, offset: 0 }], - value, - { clip: { x, y: y + 1, w: width, h: height } } - ) - selY += lineHeight - } - if (prompt) addRow(prompt, ANSI_PROMPT) - for (const row of rows) addRow(row.map((s) => s.text).join(''), ANSI_DEFAULT_FG) - if (footer) addRow(footer, FOOTER_COLOR) - - if (state === 'running') builder.animate() - return 1 + height -} - /** A `task` card's nested transcript, plus the delegate's report. */ function layoutNestedBody( builder: Builder, diff --git a/src/renderer/src/components/ContributionGraph.tsx b/src/renderer/src/components/ContributionGraph.tsx index 4c98fcd..5c1c88d 100644 --- a/src/renderer/src/components/ContributionGraph.tsx +++ b/src/renderer/src/components/ContributionGraph.tsx @@ -16,16 +16,10 @@ */ import { useEffect, useMemo, useRef, useState } from 'react' import type { ActivityDay, ActivityStats } from '@shared/types' -import { - BAYER, - OFF_TIER, - bloomLayerStyle, - clamp01, - easeOutCubic, - prefersReducedMotion -} from './dither-kit/dither-paint' +import { BAYER, OFF_TIER, bloomLayerStyle, clamp01, easeOutCubic } from './dither-kit/dither-paint' import { PALETTE, rgb } from './dither-kit/palette' import { useChartDimensions } from './dither-kit/use-chart-dimensions' +import { useMotion } from '../lib/motion' const ROWS = 7 // days of the week (Sun → Sat) // Weekday gutter labels, GitHub-style: only every other row (Mon/Wed/Fri) so the @@ -199,6 +193,7 @@ function monthLabels(columns: Cell[][], step: number): { text: string; left: num * full year of data across the container width, so it fills the card. */ export function ContributionGraph({ data }: { data: ActivityStats }): JSX.Element { + const { reduced: reduce } = useMotion() const { ref: wrapRef, size } = useChartDimensions() const canvasRef = useRef(null) const bloomRef = useRef(null) @@ -241,8 +236,6 @@ export function ContributionGraph({ data }: { data: ActivityStats }): JSX.Elemen cv.style.height = `${gridH}px` } - const reduce = prefersReducedMotion() - const paint = (prog: number): void => { const cols2 = columnsRef.current ctx.clearRect(0, 0, canvas.width, canvas.height) @@ -304,7 +297,7 @@ export function ContributionGraph({ data }: { data: ActivityStats }): JSX.Elemen running = false cancelAnimationFrame(raf) } - }, [columns, gridW, gridH, step, cellSize, radius, size.width]) + }, [columns, gridW, gridH, step, cellSize, radius, size.width, reduce]) const onPointerMove = (e: React.PointerEvent): void => { const rect = e.currentTarget.getBoundingClientRect() diff --git a/src/renderer/src/components/DitherGradient.tsx b/src/renderer/src/components/DitherGradient.tsx index 4875159..d3da495 100644 --- a/src/renderer/src/components/DitherGradient.tsx +++ b/src/renderer/src/components/DitherGradient.tsx @@ -7,11 +7,11 @@ import { clamp01, easeInOutCubic, easeOutCubic, - prefersReducedMotion, type BloomInput } from './dither-kit/dither-paint' import type { AreaVariant } from './dither-kit/chart-context' import { PALETTE, rgb, type DitherColor } from './dither-kit/palette' +import { useMotion } from '../lib/motion' export type DitherDirection = 'top' | 'bottom' | 'left' | 'right' @@ -83,6 +83,7 @@ export function DitherGradient({ const crispRef = useRef(null) const bloomRef = useRef(null) const starsRef = useRef(null) + const { reduced } = useMotion() useEffect(() => { const host = hostRef.current @@ -92,7 +93,6 @@ export function DitherGradient({ if (!host || !crisp) return const seed = PALETTE[from] - const reduced = prefersReducedMotion() let raf = 0 let cols = 0 let rows = 0 @@ -280,7 +280,18 @@ export function DitherGradient({ cancelAnimationFrame(raf) ro.disconnect() } - }, [from, direction, variant, animate, animationDuration, replayToken, stars, turn, turnDuration]) + }, [ + from, + direction, + variant, + animate, + animationDuration, + replayToken, + stars, + turn, + turnDuration, + reduced + ]) const glowStyle = bloomLayerStyle(bloom, true) diff --git a/src/renderer/src/components/MotionSettings.tsx b/src/renderer/src/components/MotionSettings.tsx new file mode 100644 index 0000000..bacdb29 --- /dev/null +++ b/src/renderer/src/components/MotionSettings.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { normalizeMotion, type MotionPreference } from '@shared/motion' +import { useMotion } from '../lib/motion' + +export function MotionSettings({ + onChange +}: { + onChange: (preference: MotionPreference) => Promise +}): JSX.Element { + const { t } = useTranslation() + const { preference } = useMotion() + const [saving, setSaving] = useState(false) + const [failed, setFailed] = useState(false) + return ( +
+

+ {t('settings.motion.heading')} +

+
+
+ +

+ {t('settings.motion.description')} +

+ {failed && ( +

+ {t('settings.motion.saveFailed')} +

+ )} +
+ +
+
+ ) +} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index c57dc3b..1f22c0b 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -9,6 +9,7 @@ import { import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import type { TFunction } from 'i18next' +import { useMotion } from '../lib/motion' import { FolderOpen, GitBranch, @@ -123,12 +124,11 @@ const FOLDER_OPEN = * 1. CSS can only interpolate `d` when both paths share an identical command * sequence, which lucide's two folders do NOT - matching them by hand meant * redrawing one icon to fit the other, and the result barely moved. - * 2. A CSS transition is silently zeroed for anyone whose OS asks for reduced - * motion - which on Windows includes everyone who turned off window - * animations. That is why the morph first shipped looking like a plain - * swap. See `reducedMotion` below. + * 2. The explicit reducedMotion prop follows Roxy's app preference, so this + * library and the canvas progress indicators agree about whether to animate. */ function FolderMorph({ open, className }: { open: boolean; className?: string }): JSX.Element { + const { reduced } = useMotion() return ( ) diff --git a/src/renderer/src/components/ThinkingIndicator.tsx b/src/renderer/src/components/ThinkingIndicator.tsx index 8bc4320..feaaa55 100644 --- a/src/renderer/src/components/ThinkingIndicator.tsx +++ b/src/renderer/src/components/ThinkingIndicator.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { cn } from '../lib/cn' +import { useMotion } from '../lib/motion' /** * A single-character braille spinner — a minimal, infinitely-looping loading @@ -14,14 +15,19 @@ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', ' * it (size/color) via `className`. */ export function BrailleSpinner({ className }: { className?: string }): JSX.Element { + const { reduced } = useMotion() const [i, setI] = useState(0) useEffect(() => { + if (reduced) return const spin = setInterval(() => setI((n) => (n + 1) % FRAMES.length), 90) return () => clearInterval(spin) - }, []) + }, [reduced]) return ( - - {FRAMES[i]} + + {FRAMES[reduced ? 0 : i]} ) } diff --git a/src/renderer/src/components/dither-kit/bar-canvas.tsx b/src/renderer/src/components/dither-kit/bar-canvas.tsx index 3664f50..1711da3 100644 --- a/src/renderer/src/components/dither-kit/bar-canvas.tsx +++ b/src/renderer/src/components/dither-kit/bar-canvas.tsx @@ -2,14 +2,8 @@ import { useEffect, useRef } from 'react' import { useChart } from './chart-context' -import { - backingSize, - bloomLayerStyle, - clamp01, - easeOutCubic, - paintColumn, - prefersReducedMotion -} from './dither-paint' +import { backingSize, bloomLayerStyle, clamp01, easeOutCubic, paintColumn } from './dither-paint' +import { useMotion } from '../../lib/motion' type Bars = { top: number[]; base: number[] } // per data index, in backing rows @@ -25,6 +19,7 @@ const STAGGER = 0.55 * hovered category lifts while the rest dim. */ export function BarCanvas() { + const { reduced: reduce } = useMotion() const ctx = useChart() const canvasRef = useRef(null) const bloomRef = useRef(null) @@ -72,7 +67,6 @@ export function BarCanvas() { bloomCanvas.height = rows } - const reduce = prefersReducedMotion() const animate = state.current.animate && !reduce const duration = state.current.animationDuration const fx = cols / Math.max(width, 1) @@ -181,7 +175,7 @@ export function BarCanvas() { raf = requestAnimationFrame(draw) return () => cancelAnimationFrame(raf) - }, [cols, rows, width]) + }, [cols, rows, width, reduce]) const bloomActive = ctx.bloomOnHover ? ctx.isMouseInChart || ctx.hovered : true const bloom = bloomLayerStyle(ctx.bloom, bloomActive) diff --git a/src/renderer/src/components/dither-kit/dither-paint.ts b/src/renderer/src/components/dither-kit/dither-paint.ts index b539cbd..9e4c2dc 100644 --- a/src/renderer/src/components/dither-kit/dither-paint.ts +++ b/src/renderer/src/components/dither-kit/dither-paint.ts @@ -166,8 +166,3 @@ export function bloomLayerStyle(input: BloomInput, active: boolean): BloomStyle export const easeInOutCubic = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2) export const easeOutCubic = (t: number) => 1 - (1 - t) ** 3 export const clamp01 = (t: number) => (t < 0 ? 0 : t > 1 ? 1 : t) - -/** Whether the OS asks for reduced motion (snap + steady stars). */ -export function prefersReducedMotion() { - return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false -} diff --git a/src/renderer/src/lib/motion.ts b/src/renderer/src/lib/motion.ts new file mode 100644 index 0000000..b5dd5c4 --- /dev/null +++ b/src/renderer/src/lib/motion.ts @@ -0,0 +1,82 @@ +import { useSyncExternalStore } from 'react' +import { + DEFAULT_MOTION, + normalizeMotion, + reduceMotion, + type MotionPreference +} from '@shared/motion' + +const CACHE_KEY = 'roxy.motion.v1' +const system = window.matchMedia('(prefers-reduced-motion: reduce)') +const listeners = new Set<() => void>() +let snapshot = { preference: DEFAULT_MOTION, reduced: false } +let revision = 0 + +function update(preference = snapshot.preference): void { + const reduced = reduceMotion(preference, system.matches) + document.documentElement.dataset.motion = reduced ? 'reduced' : 'full' + if (snapshot.preference === preference && snapshot.reduced === reduced) return + snapshot = { preference, reduced } + for (const notify of listeners) notify() +} + +export function applyMotion(value: unknown): void { + const preference = normalizeMotion(value) + revision++ + update(preference) + try { + localStorage.setItem(CACHE_KEY, preference) + } catch { + /* Storage is only a first-paint cache. */ + } +} + +export function motionSnapshot(): typeof snapshot { + return snapshot +} + +export function prefersReducedMotion(): boolean { + return snapshot.reduced +} + +export function subscribeMotion(notify: () => void): () => void { + listeners.add(notify) + return () => { + listeners.delete(notify) + } +} + +export function useMotion(): typeof snapshot { + return useSyncExternalStore(subscribeMotion, motionSnapshot, motionSnapshot) +} + +/** Prime before the first paint; SQLite remains authoritative, just like the theme cache. */ +export function startMotion(): () => void { + let cached: string | null = null + try { + cached = localStorage.getItem(CACHE_KEY) + } catch { + /* Use the default. */ + } + applyMotion(cached) + const onSystemChange = (): void => update() + system.addEventListener('change', onSystemChange) + const settings = window.roxy?.settings + let disposed = false + const atStart = revision + // A Vite reload can briefly pair this renderer with the previous preload. + const off = settings?.onMotionChanged?.(applyMotion) + void settings + ?.getAll() + .then((settings) => { + if (!disposed && revision === atStart) applyMotion(settings.motion) + }) + .catch(() => {}) + return () => { + disposed = true + off?.() + system.removeEventListener('change', onSystemChange) + } +} + +export type { MotionPreference } diff --git a/src/renderer/src/lib/store.ts b/src/renderer/src/lib/store.ts index fb263ff..3ec03f6 100644 --- a/src/renderer/src/lib/store.ts +++ b/src/renderer/src/lib/store.ts @@ -41,6 +41,8 @@ import { import { uniqueSlug } from '@shared/slugs' import { shouldAutoWorkstream, statusKeyForSession } from '@shared/workstream' import { api } from './api' +import { applyMotion, motionSnapshot, type MotionPreference } from './motion' +import { createStreamPublisher, type StreamPublisher } from './stream-publisher' import type { ComposerImage } from './images' import type { GitStatusView, @@ -222,6 +224,7 @@ interface RoxyStore { setTelemetryEnabled: (enabled: boolean) => Promise setBranchPrefix: (prefix: string) => Promise setLanguage: (language: Language) => Promise + setMotion: (preference: MotionPreference) => Promise selectChat: (id: string) => Promise clearActive: () => void newSession: () => Promise @@ -395,69 +398,6 @@ let hiddenModelsLoaded = false /** Set when a remote turn lands while a local send streams into the shared chat. */ const remoteMirror = { deferred: false } -/** - * Publish a session's live parts at most once per animation frame. - * - * All three streaming paths (a local send, a mirrored phone turn, a subagent's - * own session) used to write `streamingChats[id]` synchronously on every delta. - * A fast model emits those far quicker than the display can show them, so the - * transcript re-rendered several hundred times a second — re-parsing the turn's - * markdown and re-measuring the scroll column each time — and every render past - * the first in a frame was discarded without ever being painted. That was the - * bulk of the "the app is laggy while it streams" problem. - * - * Coalescing is lossless here because the parts array is CUMULATIVE: each delta - * produces a complete new snapshot of the turn, so the newest one already - * contains every skipped update. - * - * `null` (turn over) is published synchronously and cancels anything pending. - * It's the one update whose ordering matters: the persisted message is appended - * right after, and a frame landing later would resurrect the live bubble on top - * of it — a visibly duplicated reply. - */ -interface StreamPublisher { - (parts: MessagePart[] | null): void - /** Drop a scheduled frame without publishing it (the chat went away). */ - cancel(): void -} - -function createStreamPublisher(chatId: string): StreamPublisher { - let pending: MessagePart[] | null = null - let frame = 0 - const publish = (parts: MessagePart[] | null): void => - useRoxyStore.setState((s) => { - const next = { ...s.streamingChats } - if (parts === null) delete next[chatId] - else next[chatId] = parts - return { streamingChats: next } - }) - const cancel = (): void => { - if (frame) cancelAnimationFrame(frame) - frame = 0 - pending = null - } - const publisher = (parts: MessagePart[] | null): void => { - if (parts === null) { - cancel() - publish(null) - return - } - // Only the payload is replaced; the already-scheduled frame picks up - // whichever snapshot was last. Re-scheduling would land on the same frame - // boundary anyway, so this is a rate limit rather than a debounce — the - // first delta of a turn still paints on the very next frame. - pending = parts - if (frame) return - frame = requestAnimationFrame(() => { - frame = 0 - if (pending) publish(pending) - pending = null - }) - } - publisher.cancel = cancel - return publisher -} - /** * One publisher per streaming session. Coalescing only works if consecutive * deltas reach the SAME publisher, and two of the three paths (remote mirror, @@ -473,7 +413,14 @@ const streamPublishers = new Map() function publishStream(chatId: string, parts: MessagePart[] | null): void { let publisher = streamPublishers.get(chatId) if (!publisher) { - publisher = createStreamPublisher(chatId) + publisher = createStreamPublisher((parts) => + useRoxyStore.setState((s) => { + const next = { ...s.streamingChats } + if (parts === null) delete next[chatId] + else next[chatId] = parts + return { streamingChats: next } + }) + ) streamPublishers.set(chatId, publisher) } publisher(parts) @@ -1570,6 +1517,19 @@ export const useRoxyStore = create((set, get) => ({ set({ settings }) }, + setMotion: async (preference) => { + const previous = motionSnapshot().preference + applyMotion(preference) + try { + const settings = await api.settings.setMotion(preference) + applyMotion(settings.motion) + set((s) => ({ settings: s.settings ? { ...s.settings, motion: settings.motion } : settings })) + } catch (error) { + applyMotion(previous) + throw error + } + }, + selectChat: async (id) => { // Per-chat send state survives switching — just swap which chat is shown. // Clear messages/queue first so the previous chat's content never flashes. diff --git a/src/renderer/src/lib/stream-publisher.ts b/src/renderer/src/lib/stream-publisher.ts new file mode 100644 index 0000000..e516d88 --- /dev/null +++ b/src/renderer/src/lib/stream-publisher.ts @@ -0,0 +1,41 @@ +import type { MessagePart } from '@shared/types' + +export interface StreamPublisher { + (parts: MessagePart[] | null): void + cancel(): void +} + +/** Coalesce cumulative snapshots, but don't strand them behind an occluded window's rAF. */ +export function createStreamPublisher( + publish: (parts: MessagePart[] | null) => void +): StreamPublisher { + let pending: MessagePart[] | null = null + let frame = 0 + let timer: ReturnType | undefined + const cancel = (): void => { + if (frame) cancelAnimationFrame(frame) + if (timer !== undefined) clearTimeout(timer) + frame = 0 + timer = undefined + pending = null + } + const flush = (): void => { + const latest = pending + cancel() + if (latest) publish(latest) + } + const publisher: StreamPublisher = (parts) => { + if (parts === null) { + // Completion must win over queued deltas; otherwise a finished bubble can reappear. + cancel() + publish(null) + return + } + pending = parts + if (frame || timer !== undefined) return + frame = requestAnimationFrame(flush) + timer = setTimeout(flush, 50) + } + publisher.cancel = cancel + return publisher +} diff --git a/src/renderer/src/locales/ar.json b/src/renderer/src/locales/ar.json index e0a189a..8412fdd 100644 --- a/src/renderer/src/locales/ar.json +++ b/src/renderer/src/locales/ar.json @@ -414,6 +414,15 @@ "someHidden": "{{shown}} of {{total}} shown · {{hidden}} hidden", "unhide": "Unhide" }, + "motion": { + "description": "Choose how Roxy animates. On keeps normal animations enabled without changing your operating system settings. Reduced removes movement while keeping gentle progress feedback. Streaming speed is unchanged.", + "heading": "Appearance", + "label": "Motion", + "on": "On (default)", + "reduced": "Reduced", + "saveFailed": "Couldn't save the motion preference. Try again.", + "system": "Follow system" + }, "privacy": { "description": "العدد والتوقيتات — تشغيل التطبيق، الأدوار المكتملة، عدد الخطوات والأدوات التي استغرقها الدور، عدد الرموز المميزة التي استخدمها وما يقرب من تكلفتها — مرتبطة بمعرف عشوائي تم إنشاؤه على هذا الجهاز. لا يتم تضمين مطالباتك، أو التعليمات البرمجية الخاصة بك، أو مسارات الملفات، أو أسماء المستودعات، أو أي نص خطأ.", "heading": "الخصوصية", @@ -585,9 +594,11 @@ "collapse": "Collapse", "copy": "copy", "copyCode": "Copy code", + "copyCommand": "Copy command", "copyFailed": "Could not copy to the clipboard. Try again with the app focused.", "copyMessage": "Copy message", "copyNewContents": "Copy the new contents", + "copyOutput": "Copy output", "copySelection": "Copy selection", "expand": "Expand", "history": "Chat history", diff --git a/src/renderer/src/locales/de.json b/src/renderer/src/locales/de.json index 60a9ff2..01c5d46 100644 --- a/src/renderer/src/locales/de.json +++ b/src/renderer/src/locales/de.json @@ -414,6 +414,15 @@ "someHidden": "{{shown}} of {{total}} shown · {{hidden}} hidden", "unhide": "Unhide" }, + "motion": { + "description": "Choose how Roxy animates. On keeps normal animations enabled without changing your operating system settings. Reduced removes movement while keeping gentle progress feedback. Streaming speed is unchanged.", + "heading": "Appearance", + "label": "Motion", + "on": "On (default)", + "reduced": "Reduced", + "saveFailed": "Couldn't save the motion preference. Try again.", + "system": "Follow system" + }, "privacy": { "description": "Zählungen und Zeitangaben – App-Starts, abgeschlossene Züge, wie viele Schritte und Tools ein Zug benötigte, wie viele Token er verbrauchte und was er ungefähr kostete – verknüpft mit einer zufälligen ID, die auf diesem Gerät generiert wurde. Niemals Ihre Prompts, Ihr Code, Dateipfade, Repo-Namen oder Fehlermeldungen.", "heading": "Datenschutz", @@ -585,9 +594,11 @@ "collapse": "Collapse", "copy": "copy", "copyCode": "Copy code", + "copyCommand": "Copy command", "copyFailed": "Could not copy to the clipboard. Try again with the app focused.", "copyMessage": "Copy message", "copyNewContents": "Copy the new contents", + "copyOutput": "Copy output", "copySelection": "Copy selection", "expand": "Expand", "history": "Chat history", diff --git a/src/renderer/src/locales/default.json b/src/renderer/src/locales/default.json index efec789..9d5a189 100644 --- a/src/renderer/src/locales/default.json +++ b/src/renderer/src/locales/default.json @@ -322,6 +322,15 @@ "openPort": "Open localhost:{{port}} in this session's browser" }, "settings": { + "motion": { + "heading": "Appearance", + "label": "Motion", + "description": "Choose how Roxy animates. On keeps normal animations enabled without changing your operating system settings. Reduced removes movement while keeping gentle progress feedback. Streaming speed is unchanged.", + "on": "On (default)", + "system": "Follow system", + "reduced": "Reduced", + "saveFailed": "Couldn't save the motion preference. Try again." + }, "title": "Settings", "providers": { "heading": "Providers", @@ -579,6 +588,8 @@ "report": "Report", "copy": "copy", "copyCode": "Copy code", + "copyCommand": "Copy command", + "copyOutput": "Copy output", "copyNewContents": "Copy the new contents", "expand": "Expand", "collapse": "Collapse", diff --git a/src/renderer/src/locales/es.json b/src/renderer/src/locales/es.json index 2740970..08cb689 100644 --- a/src/renderer/src/locales/es.json +++ b/src/renderer/src/locales/es.json @@ -414,6 +414,15 @@ "someHidden": "{{shown}} of {{total}} shown · {{hidden}} hidden", "unhide": "Unhide" }, + "motion": { + "description": "Choose how Roxy animates. On keeps normal animations enabled without changing your operating system settings. Reduced removes movement while keeping gentle progress feedback. Streaming speed is unchanged.", + "heading": "Appearance", + "label": "Motion", + "on": "On (default)", + "reduced": "Reduced", + "saveFailed": "Couldn't save the motion preference. Try again.", + "system": "Follow system" + }, "privacy": { "description": "Recuentos y tiempos —arranques de la aplicación, turnos completados, cuántos pasos y herramientas necesitó un turno, cuántos tokens consumió y aproximadamente cuánto costó— asociados a un identificador aleatorio generado en este equipo. Nunca tus prompts, tu código, rutas de archivos, nombres de repositorios ni texto de errores.", "heading": "Privacidad", @@ -585,9 +594,11 @@ "collapse": "Collapse", "copy": "copy", "copyCode": "Copy code", + "copyCommand": "Copy command", "copyFailed": "Could not copy to the clipboard. Try again with the app focused.", "copyMessage": "Copy message", "copyNewContents": "Copy the new contents", + "copyOutput": "Copy output", "copySelection": "Copy selection", "expand": "Expand", "history": "Chat history", diff --git a/src/renderer/src/locales/fr.json b/src/renderer/src/locales/fr.json index fcaff32..08e2401 100644 --- a/src/renderer/src/locales/fr.json +++ b/src/renderer/src/locales/fr.json @@ -414,6 +414,15 @@ "someHidden": "{{shown}} of {{total}} shown · {{hidden}} hidden", "unhide": "Unhide" }, + "motion": { + "description": "Choose how Roxy animates. On keeps normal animations enabled without changing your operating system settings. Reduced removes movement while keeping gentle progress feedback. Streaming speed is unchanged.", + "heading": "Appearance", + "label": "Motion", + "on": "On (default)", + "reduced": "Reduced", + "saveFailed": "Couldn't save the motion preference. Try again.", + "system": "Follow system" + }, "privacy": { "description": "Comptes et chronométrages — lancements d'applications, tours terminés, nombre d'étapes et d'outils qu'un tour a pris, nombre de jetons utilisés et coût approximatif — liés à un identifiant aléatoire généré sur cette machine. Jamais vos invites, votre code, les chemins de fichiers, les noms de dépôts ou tout texte d'erreur.", "heading": "Confidentialité", @@ -585,9 +594,11 @@ "collapse": "Collapse", "copy": "copy", "copyCode": "Copy code", + "copyCommand": "Copy command", "copyFailed": "Could not copy to the clipboard. Try again with the app focused.", "copyMessage": "Copy message", "copyNewContents": "Copy the new contents", + "copyOutput": "Copy output", "copySelection": "Copy selection", "expand": "Expand", "history": "Chat history", diff --git a/src/renderer/src/locales/hi.json b/src/renderer/src/locales/hi.json index 2e62b62..64e953e 100644 --- a/src/renderer/src/locales/hi.json +++ b/src/renderer/src/locales/hi.json @@ -414,6 +414,15 @@ "someHidden": "{{shown}} of {{total}} shown · {{hidden}} hidden", "unhide": "Unhide" }, + "motion": { + "description": "Choose how Roxy animates. On keeps normal animations enabled without changing your operating system settings. Reduced removes movement while keeping gentle progress feedback. Streaming speed is unchanged.", + "heading": "Appearance", + "label": "Motion", + "on": "On (default)", + "reduced": "Reduced", + "saveFailed": "Couldn't save the motion preference. Try again.", + "system": "Follow system" + }, "privacy": { "description": "गणना और समय — ऐप लॉन्च, समाप्त मोड़, एक मोड़ में कितने कदम और उपकरण लगे, इसने कितने टोकन का उपयोग किया और मोटे तौर पर इसकी लागत कितनी थी — इस मशीन पर उत्पन्न एक यादृच्छिक आईडी से जुड़ा हुआ। कभी भी आपके प्रॉम्प्ट, आपका कोड, फ़ाइल पथ, रेपो नाम, या कोई त्रुटि पाठ नहीं।", "heading": "गोपनीयता", @@ -585,9 +594,11 @@ "collapse": "Collapse", "copy": "copy", "copyCode": "Copy code", + "copyCommand": "Copy command", "copyFailed": "Could not copy to the clipboard. Try again with the app focused.", "copyMessage": "Copy message", "copyNewContents": "Copy the new contents", + "copyOutput": "Copy output", "copySelection": "Copy selection", "expand": "Expand", "history": "Chat history", diff --git a/src/renderer/src/locales/ja.json b/src/renderer/src/locales/ja.json index a873727..287c671 100644 --- a/src/renderer/src/locales/ja.json +++ b/src/renderer/src/locales/ja.json @@ -414,6 +414,15 @@ "someHidden": "{{shown}} of {{total}} shown · {{hidden}} hidden", "unhide": "Unhide" }, + "motion": { + "description": "Choose how Roxy animates. On keeps normal animations enabled without changing your operating system settings. Reduced removes movement while keeping gentle progress feedback. Streaming speed is unchanged.", + "heading": "Appearance", + "label": "Motion", + "on": "On (default)", + "reduced": "Reduced", + "saveFailed": "Couldn't save the motion preference. Try again.", + "system": "Follow system" + }, "privacy": { "description": "アプリの起動、完了したターン、ターンにかかったステップとツールの数、使用されたトークンの数、おおよそのコストなど、このマシンで生成されたランダムなIDに関連付けられたカウントとタイミング。プロンプト、コード、ファイルパス、リポジトリ名、エラーテキストは決して含まれません。", "heading": "プライバシー", @@ -585,9 +594,11 @@ "collapse": "Collapse", "copy": "copy", "copyCode": "Copy code", + "copyCommand": "Copy command", "copyFailed": "Could not copy to the clipboard. Try again with the app focused.", "copyMessage": "Copy message", "copyNewContents": "Copy the new contents", + "copyOutput": "Copy output", "copySelection": "Copy selection", "expand": "Expand", "history": "Chat history", diff --git a/src/renderer/src/locales/pt.json b/src/renderer/src/locales/pt.json index 8bc04b2..5171c79 100644 --- a/src/renderer/src/locales/pt.json +++ b/src/renderer/src/locales/pt.json @@ -414,6 +414,15 @@ "someHidden": "{{shown}} of {{total}} shown · {{hidden}} hidden", "unhide": "Unhide" }, + "motion": { + "description": "Choose how Roxy animates. On keeps normal animations enabled without changing your operating system settings. Reduced removes movement while keeping gentle progress feedback. Streaming speed is unchanged.", + "heading": "Appearance", + "label": "Motion", + "on": "On (default)", + "reduced": "Reduced", + "saveFailed": "Couldn't save the motion preference. Try again.", + "system": "Follow system" + }, "privacy": { "description": "Contagens e tempos — inicializações de aplicativos, turnos concluídos, quantos passos e ferramentas um turno levou, quantos tokens usou e aproximadamente quanto custou — vinculados a um ID aleatório gerado nesta máquina. Nunca seus prompts, seu código, caminhos de arquivo, nomes de repositórios ou qualquer texto de erro.", "heading": "Privacidade", @@ -585,9 +594,11 @@ "collapse": "Collapse", "copy": "copy", "copyCode": "Copy code", + "copyCommand": "Copy command", "copyFailed": "Could not copy to the clipboard. Try again with the app focused.", "copyMessage": "Copy message", "copyNewContents": "Copy the new contents", + "copyOutput": "Copy output", "copySelection": "Copy selection", "expand": "Expand", "history": "Chat history", diff --git a/src/renderer/src/locales/ru.json b/src/renderer/src/locales/ru.json index 8e2cceb..b5d0e14 100644 --- a/src/renderer/src/locales/ru.json +++ b/src/renderer/src/locales/ru.json @@ -414,6 +414,15 @@ "someHidden": "{{shown}} of {{total}} shown · {{hidden}} hidden", "unhide": "Unhide" }, + "motion": { + "description": "Choose how Roxy animates. On keeps normal animations enabled without changing your operating system settings. Reduced removes movement while keeping gentle progress feedback. Streaming speed is unchanged.", + "heading": "Appearance", + "label": "Motion", + "on": "On (default)", + "reduced": "Reduced", + "saveFailed": "Couldn't save the motion preference. Try again.", + "system": "Follow system" + }, "privacy": { "description": "Количество и время — запуски приложений, завершенные ходы, сколько шагов и инструментов занял ход, сколько токенов он использовал и примерно сколько это стоило — привязаны к случайному идентификатору, сгенерированному на этой машине. Никогда не ваши запросы, ваш код, пути к файлам, имена репозиториев или любой текст ошибки.", "heading": "Конфиденциальность", @@ -585,9 +594,11 @@ "collapse": "Collapse", "copy": "copy", "copyCode": "Copy code", + "copyCommand": "Copy command", "copyFailed": "Could not copy to the clipboard. Try again with the app focused.", "copyMessage": "Copy message", "copyNewContents": "Copy the new contents", + "copyOutput": "Copy output", "copySelection": "Copy selection", "expand": "Expand", "history": "Chat history", diff --git a/src/renderer/src/locales/zh.json b/src/renderer/src/locales/zh.json index 75f0535..0332627 100644 --- a/src/renderer/src/locales/zh.json +++ b/src/renderer/src/locales/zh.json @@ -414,6 +414,15 @@ "someHidden": "{{shown}} of {{total}} shown · {{hidden}} hidden", "unhide": "Unhide" }, + "motion": { + "description": "Choose how Roxy animates. On keeps normal animations enabled without changing your operating system settings. Reduced removes movement while keeping gentle progress feedback. Streaming speed is unchanged.", + "heading": "Appearance", + "label": "Motion", + "on": "On (default)", + "reduced": "Reduced", + "saveFailed": "Couldn't save the motion preference. Try again.", + "system": "Follow system" + }, "privacy": { "description": "计数和时间 — 应用程序启动、完成的回合、一个回合所需的步骤和工具数量、使用的令牌数量以及大致成本 — 与此机器上生成的随机 ID 绑定。绝不包含您的提示、代码、文件路径、仓库名称或任何错误文本。", "heading": "隐私", @@ -585,9 +594,11 @@ "collapse": "Collapse", "copy": "copy", "copyCode": "Copy code", + "copyCommand": "Copy command", "copyFailed": "Could not copy to the clipboard. Try again with the app focused.", "copyMessage": "Copy message", "copyNewContents": "Copy the new contents", + "copyOutput": "Copy output", "copySelection": "Copy selection", "expand": "Expand", "history": "Chat history", diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 2b88036..c182cf3 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -11,6 +11,7 @@ import App from './App' import { AppContextMenu } from './components/AppContextMenu' import { installSquircle } from './lib/squircle' import { primeTheme, startTheme } from './lib/theme' +import { startMotion } from './lib/motion' // Tag the platform so CSS can reserve room for the native window controls // (traffic lights on macOS, control overlay on Windows/Linux). @@ -21,6 +22,7 @@ document.documentElement.dataset.platform = window.electron?.process?.platform ? // the built-in dark palette on the way to a light theme. primeTheme() startTheme() +startMotion() // Upgrade every `.sq*` corner from a quarter-circle to a superellipse. Async and // purely additive -- the first frame paints with the plain `rounded-*` fallback. diff --git a/src/renderer/src/routes/Settings.tsx b/src/renderer/src/routes/Settings.tsx index 237cbfc..b9709b1 100644 --- a/src/renderer/src/routes/Settings.tsx +++ b/src/renderer/src/routes/Settings.tsx @@ -26,6 +26,7 @@ import { ProviderLogo } from '../lib/providerLogos' import { SubscriptionAccounts } from '../components/SubscriptionSetup' import { ModelVisibility } from '../components/ModelVisibility' import { useRoxyStore } from '../lib/store' +import { MotionSettings } from '../components/MotionSettings' /** The section heading repeated down the page. */ const SECTION_HEADING = 'mb-3 text-xs font-semibold uppercase tracking-wide text-text-subtle' @@ -42,6 +43,7 @@ export default function Settings(): JSX.Element { const setTelemetryEnabled = useRoxyStore((s) => s.setTelemetryEnabled) const setBranchPrefix = useRoxyStore((s) => s.setBranchPrefix) const setLanguage = useRoxyStore((s) => s.setLanguage) + const setMotion = useRoxyStore((s) => s.setMotion) const [prefix, setPrefix] = useState('') const prefixError = branchPrefixError(prefix) // Pinned once per mount: a preview that reshuffled on every keystroke @@ -133,6 +135,7 @@ export default function Settings(): JSX.Element { return ( navigate('/')}> +

{t('settings.providers.heading')}

diff --git a/src/renderer/src/routes/onboarding/WelcomeStep.tsx b/src/renderer/src/routes/onboarding/WelcomeStep.tsx index 3a84028..d17695b 100644 --- a/src/renderer/src/routes/onboarding/WelcomeStep.tsx +++ b/src/renderer/src/routes/onboarding/WelcomeStep.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { ArrowRight } from 'lucide-react' import { useTranslation } from 'react-i18next' import { DitherGradient } from '../../components/DitherGradient' +import { useMotion } from '../../lib/motion' /** * Cycled on the welcome screen. `lang` picks the right font fallbacks and tells @@ -38,14 +39,16 @@ const HOLD_MS = 3400 * a greeting that drifts between languages, and a single way forward. */ export function WelcomeStep({ onContinue }: { onContinue: () => void }): JSX.Element { + const { reduced } = useMotion() const { t } = useTranslation() const [index, setIndex] = useState(0) const [visible, setVisible] = useState(true) useEffect(() => { - // Honour the OS setting — a word swapping on a timer is exactly the kind - // of motion this covers. Reduced motion keeps the first greeting still. - if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return + if (reduced) { + setVisible(true) + return + } let swap: ReturnType const hold = setTimeout(() => { @@ -62,7 +65,7 @@ export function WelcomeStep({ onContinue }: { onContinue: () => void }): JSX.Ele clearTimeout(hold) clearTimeout(swap) } - }, [index]) + }, [index, reduced]) const greeting = GREETINGS[index] @@ -83,7 +86,9 @@ export function WelcomeStep({ onContinue }: { onContinue: () => void }): JSX.Ele opacity: visible ? 1 : 0, // Barely-there drift, so it reads as a breeze rather than a slide. transform: visible ? 'translateY(0)' : 'translateY(6px)', - transition: `opacity ${FADE_MS}ms cubic-bezier(0.4, 0, 0.2, 1), transform ${FADE_MS}ms cubic-bezier(0.4, 0, 0.2, 1)`, + transition: reduced + ? 'none' + : `opacity ${FADE_MS}ms cubic-bezier(0.4, 0, 0.2, 1), transform ${FADE_MS}ms cubic-bezier(0.4, 0, 0.2, 1)`, // Promote to its own layer so the fade composites on the GPU and // never re-rasterizes the text against the canvases behind it. willChange: 'opacity, transform' diff --git a/src/shared/api.ts b/src/shared/api.ts index 9a4004e..142a31a 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -3,6 +3,7 @@ * Implemented in src/preload/index.ts, handled in src/main/ipc/*. */ import type { Language } from './i18n' +import type { MotionPreference } from './motion' import type { AddMessageInput, AppSettings, @@ -719,6 +720,9 @@ export interface RoxyApi { setBranchPrefix(prefix: string): Promise /** Set the UI language. An unknown code falls back to English. */ setLanguage(language: Language): Promise + setMotion(motion: MotionPreference): Promise + /** Keep the app and its browser toolbar in sync; never changes OS preferences. */ + onMotionChanged(callback: (motion: MotionPreference) => void): () => void completeOnboarding(): Promise reset(): Promise /** diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 5bec2a4..a2af080 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -8,6 +8,8 @@ export const CHANNELS = { settingsSetAutoWorkstream: 'settings:setAutoWorkstream', settingsSetBranchPrefix: 'settings:setBranchPrefix', settingsSetLanguage: 'settings:setLanguage', + settingsSetMotion: 'settings:setMotion', + settingsMotionChanged: 'settings:motionChanged', settingsCompleteOnboarding: 'settings:completeOnboarding', settingsReset: 'settings:reset', // Anonymous usage tracking. Its own pair of channels rather than a field on diff --git a/src/shared/motion.ts b/src/shared/motion.ts new file mode 100644 index 0000000..561dfc6 --- /dev/null +++ b/src/shared/motion.ts @@ -0,0 +1,12 @@ +export type MotionPreference = 'on' | 'system' | 'reduced' + +export const DEFAULT_MOTION: MotionPreference = 'on' + +export function normalizeMotion(value: unknown): MotionPreference { + return value === 'system' || value === 'reduced' ? value : DEFAULT_MOTION +} + +export function reduceMotion(value: unknown, systemReduced: boolean): boolean { + const preference = normalizeMotion(value) + return preference === 'reduced' || (preference === 'system' && systemReduced) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 7937c5d..a7aab6e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -5,6 +5,7 @@ */ import type { RepoLink } from './repos' import type { Language } from './i18n' +import type { MotionPreference } from './motion' // ---- Providers --------------------------------------------------------------- @@ -426,6 +427,8 @@ export interface AppSettings { * would surprise anyone who works in English inside a Spanish desktop. */ language: Language + /** App-only motion preference. Normal animations are on unless explicitly changed. */ + motion: MotionPreference /** * Which theme paints the UI. Null means the built-in default. * diff --git a/test/canvas/AnimationHarness.tsx b/test/canvas/AnimationHarness.tsx new file mode 100644 index 0000000..31e8fdf --- /dev/null +++ b/test/canvas/AnimationHarness.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react' +import type { Message, MessagePart } from '../../src/shared/types' +import { CanvasTranscript } from '../../src/renderer/src/canvas/CanvasTranscript' +import { HISTORY_FIXTURES, STREAMING } from './fixtures' +import { createStreamPublisher } from '../../src/renderer/src/lib/stream-publisher' +import { MotionSettings } from '../../src/renderer/src/components/MotionSettings' +import { useRoxyStore } from '../../src/renderer/src/lib/store' + +declare global { + interface Window { + __motionTest: { produced: number; painted: number; started: number; finished: number } + } +} +window.__motionTest = { produced: 0, painted: 0, started: 0, finished: 0 } +const EMPTY_HISTORY: Message[] = [] + +export function AnimationHarness(): JSX.Element { + const setMotion = useRoxyStore((state) => state.setMotion) + const [mode, setMode] = useState<'thinking' | 'tools' | 'text' | 'done'>('thinking') + const [large, setLarge] = useState(false) + const [streaming, setStreaming] = useState([]) + useEffect(() => { + const original = CanvasRenderingContext2D.prototype.fillText + CanvasRenderingContext2D.prototype.fillText = function (text: string, ...args: number[]) { + if (this.canvas.closest('[data-canvas-surface]')) { + for (const match of text.matchAll(/token([0-9]+)/g)) + window.__motionTest.painted = Math.max(window.__motionTest.painted, Number(match[1])) + } + Reflect.apply(original, this, [text, ...args]) + } + return () => { + CanvasRenderingContext2D.prototype.fillText = original + } + }, []) + useEffect(() => { + const publish = createStreamPublisher(setStreaming) + if (mode === 'thinking') { + publish([]) + return publish.cancel + } + if (mode === 'tools') { + publish(STREAMING) + return publish.cancel + } + if (mode === 'done') { + publish(null) + return + } + let count = 0 + const started = performance.now() + window.__motionTest = { produced: 0, painted: 0, started, finished: 0 } + const timer = setInterval(() => { + count++ + window.__motionTest.produced = count + publish([ + { + type: 'text', + text: Array.from( + { length: count }, + (_, i) => `token${String(i + 1).padStart(3, '0')}` + ).join(' ') + } + ]) + if (count === 80) { + clearInterval(timer) + window.__motionTest.finished = performance.now() + } + }, 16) + return () => { + clearInterval(timer) + publish.cancel() + } + }, [mode]) + return ( + <> +
+ + + + + +
+
+ +
+ + {}} + onCancelTool={() => {}} + /> + + ) +} diff --git a/test/canvas/README.md b/test/canvas/README.md index b7cf847..1fa86ca 100644 --- a/test/canvas/README.md +++ b/test/canvas/README.md @@ -2,6 +2,7 @@ - `npm run canvas` opens interactive transcript, diff, and prompt-navigation fixtures on port 3114. - `npm run smoke:canvas` starts that server and checks real Electron mouse/keyboard behavior with temporary user data. +- `npm run smoke:animation` verifies the saved On/System/Reduced motion preference, progress pixels changing over time, hide/show recovery, and streamed-text cadence with Chromium's normal throttling enabled. Uses temporary SQLite/user data, never your app settings. - `npm run smoke:diff` runs the pure geometry, diff, text-selection, and viewport-windowing checks. - `npm run perf:canvas` records switch-to-first-paint times and a Chromium CPU profile for fresh main/agent history snapshots. It fails if a switch exceeds 500 ms or if the initial viewport materializes more than 500 text rows. diff --git a/test/canvas/animation.cjs b/test/canvas/animation.cjs new file mode 100644 index 0000000..8ec2280 --- /dev/null +++ b/test/canvas/animation.cjs @@ -0,0 +1,329 @@ +const assert = require('node:assert/strict') +const { app, BrowserWindow } = require('electron') +const path = require('node:path') +const fs = require('node:fs/promises') +const os = require('node:os') +const temp = require('node:fs').mkdtempSync(path.join(os.tmpdir(), 'roxy-canvas-motion-')) +app.setPath('userData', temp) +let server, win +let checks = 0 +const errors = [] +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +const check = (name, value) => { + assert.ok(value, name) + checks++ + console.log(` OK ${name}`) +} +const evaluate = (code) => win.webContents.executeJavaScript(`{ ${code} }`, true) +const inspect = (pixels = true) => + evaluate(`(() => { + const probe=window.__canvasTranscript, scene=probe.scene(); const el=document.querySelector('[data-canvas-surface]'); + const text=scene.blocks.flatMap(b=>b.selectable).map(l=>l.text).join(''); + const tokens=[...text.matchAll(/token([0-9]+)/g)].map(m=>Number(m[1])); + return { frames:probe.debug.frames, layouts:probe.debug.layouts, produced:window.__motionTest.produced, painted:window.__motionTest.painted, + latest:tokens.length?Math.max(...tokens):0, hash:${pixels ? "document.querySelector('canvas').toDataURL()" : 'null'}, + visibility:document.visibilityState, reduced:matchMedia('(prefers-reduced-motion: reduce)').matches, + effective:document.documentElement.dataset.motion, + atBottom:Math.abs(el.scrollHeight-el.scrollTop-el.clientHeight)<2, now:performance.now() }; +})()`) +const click = async (id) => { + await evaluate(`document.querySelector('#${id}').click()`) + await wait(100) +} +const media = async (value) => { + await win.webContents.debugger.sendCommand('Emulation.setEmulatedMedia', { + features: [{ name: 'prefers-reduced-motion', value }] + }) + await wait(80) +} +const chooseMotion = async (value) => { + await evaluate(`const el=document.querySelector('#motion'); + Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype,'value').set.call(el,${JSON.stringify(value)}); + el.dispatchEvent(new Event('change',{bubbles:true}));`) + await wait(100) +} + +async function run() { + const outfile = path.join(__dirname, '../.out/motion-settings.cjs') + require('esbuild').buildSync({ + stdin: { + contents: `export {getSettings,setMotion,resetAll} from './src/main/db/repo'; + export {getDb,closeDb} from './src/main/db/database';`, + resolveDir: path.join(__dirname, '../..') + }, + bundle: true, + platform: 'node', + format: 'cjs', + packages: 'external', + outfile + }) + const settings = require(outfile) + try { + check('fresh install enables motion by default', settings.getSettings().motion === 'on') + settings.setMotion('reduced') + settings.closeDb() + check('reduced motion survives reopening SQLite', settings.getSettings().motion === 'reduced') + settings.setMotion('system') + settings.closeDb() + check('Follow system survives reopening SQLite', settings.getSettings().motion === 'system') + settings.setMotion('on') + check( + 'On clears the override and reads as the default', + settings.getSettings().motion === 'on' && + !settings.getDb().prepare("SELECT value FROM settings WHERE key='motion'").get() + ) + settings.getDb().prepare("INSERT INTO settings(key,value) VALUES('motion','unknown')").run() + check( + 'unknown persisted motion values fall back safely', + settings.getSettings().motion === 'on' + ) + settings.setMotion('reduced') + settings.resetAll() + check('factory reset restores normal motion', settings.getSettings().motion === 'on') + } finally { + settings.closeDb() + } + const { createServer } = await import('vite') + server = await createServer({ + configFile: path.join(__dirname, 'vite.config.mjs'), + server: { host: '127.0.0.1', port: 3114, strictPort: true } + }) + await server.listen() + // Use the app's default throttling policy rather than masking issues with backgroundThrottling:false. + win = new BrowserWindow({ + width: 1100, + height: 720, + show: true, + webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: false } + }) + win.setAlwaysOnTop(true) + win.webContents.on('console-message', (_event, level, message) => { + if (level >= 3) errors.push(message) + }) + await win.loadURL('http://127.0.0.1:3114/?animation') + win.show() + win.focus() + await wait(1700) + console.log( + 'Native canvas state', + await evaluate( + `({visible:document.visibilityState,reduced:matchMedia('(prefers-reduced-motion: reduce)').matches})` + ) + ) + win.webContents.debugger.attach('1.3') + check( + 'Settings starts at On', + await evaluate( + `document.querySelector('#motion').value==='on' && document.documentElement.dataset.motion==='full'` + ) + ) + await media('reduce') + check( + 'On keeps normal motion despite the OS setting', + await evaluate( + `matchMedia('(prefers-reduced-motion: reduce)').matches && document.documentElement.dataset.motion==='full'` + ) + ) + check( + 'CSS animations use the same default as canvas', + await evaluate( + `getComputedStyle(document.querySelector('[data-motion-css]')).animationName==='spin'` + ) + ) + await chooseMotion('reduced') + check( + 'Settings immediately reduces CSS and canvas motion', + await evaluate( + `document.documentElement.dataset.motion==='reduced' && getComputedStyle(document.querySelector('[data-motion-css]')).animationName==='pulse'` + ) + ) + check( + 'setting saves through the store and bridge', + await evaluate( + `localStorage.getItem('roxy.test.motion')==='reduced' && localStorage.getItem('roxy.motion.v1')==='reduced'` + ) + ) + await media('no-preference') + check( + 'explicit Reduced does not depend on OS settings', + await evaluate( + `document.documentElement.dataset.motion==='reduced' && !matchMedia('(prefers-reduced-motion: reduce)').matches` + ) + ) + await win.reload() + await wait(1600) + check( + 'saved motion is restored after window reload', + await evaluate( + `document.querySelector('#motion').value==='reduced' && document.documentElement.dataset.motion==='reduced'` + ) + ) + await chooseMotion('system') + check( + 'Follow system enables normal motion on an unreduced OS', + await evaluate(`document.documentElement.dataset.motion==='full'`) + ) + await media('reduce') + check( + 'Follow system observes OS changes live', + await evaluate(`document.documentElement.dataset.motion==='reduced'`) + ) + await chooseMotion('on') + check( + 'On restores full animation without modifying the OS', + await evaluate( + `document.documentElement.dataset.motion==='full' && matchMedia('(prefers-reduced-motion: reduce)').matches` + ) + ) + await evaluate(`window.__canvasTest.motionSaveFails=true`) + await chooseMotion('reduced') + check( + 'a failed save restores the previous preference and reports an error', + await evaluate( + `document.querySelector('#motion').value==='on' && document.querySelector('[role="alert"]')?.textContent.includes("Couldn't save") && document.documentElement.dataset.motion==='full'` + ) + ) + await evaluate(`window.__canvasTest.motionSaveFails=false`) + await chooseMotion('system') + await click('motion-tools') + await click('motion-thinking') + const nativeFirst = await inspect() + await wait(380) + const nativeNext = await inspect() + check( + 'initial reduced-motion thinking feedback remains alive', + nativeNext.frames > nativeFirst.frames && nativeNext.hash !== nativeFirst.hash + ) + await media('no-preference') + await click('motion-tools') + await click('motion-thinking') + await wait(1400) + let a = await inspect() + await wait(320) + let b = await inspect() + console.log('Thinking frames', b.frames - a.frames, 'layouts', b.layouts - a.layouts) + check( + 'thinking advances without new text or pointer input', + b.frames - a.frames >= 5 && a.hash !== b.hash + ) + check('idle progress animation does not rebuild layout', a.layouts === b.layouts) + await click('motion-tools') + a = await inspect() + await wait(320) + b = await inspect() + check('tool spinners continue across frames', b.frames - a.frames >= 5 && a.hash !== b.hash) + await media('reduce') + a = await inspect() + await wait(400) + b = await inspect() + check('reduced-motion progress is gentle, not frozen', b.frames > a.frames && a.hash !== b.hash) + await media('no-preference') + a = await inspect() + await wait(320) + b = await inspect() + check( + 'normal animation resumes when OS motion preference changes', + b.frames - a.frames >= 5 && a.hash !== b.hash + ) + await click('motion-large') + a = await inspect() + await wait(320) + b = await inspect() + check( + 'progress also animates beside windowed history', + b.frames - a.frames >= 5 && a.hash !== b.hash + ) + await evaluate(`document.querySelector('[data-canvas-surface]').scrollTop=0`) + await wait(100) + a = await inspect(false) + await wait(320) + b = await inspect(false) + check('offscreen progress does not keep repainting old messages', a.frames === b.frames) + await evaluate( + `const el=document.querySelector('[data-canvas-surface]');el.scrollTop=el.scrollHeight` + ) + await wait(100) + a = await inspect() + await wait(320) + b = await inspect() + check('returning to live tools resumes the clock', b.frames - a.frames >= 5 && a.hash !== b.hash) + await click('motion-large') + win.hide() + await wait(150) + a = await inspect(false) + await wait(320) + b = await inspect(false) + check( + 'hidden canvas pauses continuous animation', + a.visibility === 'hidden' && a.frames === b.frames + ) + win.show() + win.focus() + await wait(120) + a = await inspect() + await wait(320) + b = await inspect() + check( + 'showing the window resumes animation without new tokens', + b.frames - a.frames >= 5 && a.hash !== b.hash + ) + for (const [large, reduced] of [ + [false, false], + [true, false], + [true, true] + ]) { + if (large && !reduced) await click('motion-large') + await media(reduced ? 'reduce' : 'no-preference') + await click('motion-text') + const samples = [] + for (let i = 0; i < 24; i++) { + await wait(60) + const s = await inspect(false) + samples.push(s) + } + const final = samples.at(-1) + const changes = samples.filter((s, i) => i > 0 && s.painted !== samples[i - 1].painted).length + const worstBehind = Math.max(...samples.map((s) => s.produced - s.painted)) + console.log('Streaming cadence', { + large, + reduced, + changes, + worstBehind, + painted: final.painted, + produced: final.produced + }) + check( + `${large ? 'windowed' : 'short'} transcript shows tokens as produced${reduced ? ' with reduced motion' : ''}`, + final.painted === 80 && changes >= 8 && worstBehind <= 8 + ) + check('streaming remains pinned without a typewriter backlog', final.atBottom) + await click('motion-thinking') + } + await click('motion-done') + await wait(150) + a = await inspect() + await wait(320) + b = await inspect() + check('settled transcript stops the animation clock', a.frames === b.frames) + check('no renderer errors during animation', errors.length === 0) + console.log(`CANVAS MOTION OK - ${checks} checks passed`) +} +app.whenReady().then(async () => { + const watchdog = setTimeout(() => { + console.error('Motion test timed out') + app.exit(2) + }, 60000) + let code = 0 + try { + await run() + } catch (error) { + console.error(error) + console.error(errors) + code = 1 + } + clearTimeout(watchdog) + win?.destroy() + await server?.close() + await fs.rm(temp, { recursive: true, force: true }).catch(() => {}) + app.exit(code) +}) diff --git a/test/canvas/bridge.ts b/test/canvas/bridge.ts index a7a16e2..1d774e5 100644 --- a/test/canvas/bridge.ts +++ b/test/canvas/bridge.ts @@ -1,3 +1,5 @@ +import { normalizeMotion, type MotionPreference } from '../../src/shared/motion' + declare global { interface Window { __canvasTest: { @@ -8,6 +10,7 @@ declare global { logoPaints: number logoFallbacks: number releaseLogo: () => void + motionSaveFails: boolean } } } @@ -25,8 +28,10 @@ window.__canvasTest = { logoDecodes: 0, logoPaints: 0, logoFallbacks: 0, - releaseLogo + releaseLogo, + motionSaveFails: false } +const motionListeners = new Set<(motion: MotionPreference) => void>() // Delay decode independently of the load event to exercise message churn and cached-image remounts. const decode = HTMLImageElement.prototype.decode @@ -57,6 +62,20 @@ CanvasRenderingContext2D.prototype.fillText = function (text: string, ...args: n Reflect.apply(fillText, this, [text, ...args]) } window.roxy = { + settings: { + getAll: async () => ({ motion: normalizeMotion(localStorage.getItem('roxy.test.motion')) }), + setMotion: async (value: MotionPreference) => { + if (window.__canvasTest.motionSaveFails) throw new Error('Test motion write failure') + const motion = normalizeMotion(value) + localStorage.setItem('roxy.test.motion', motion) + for (const listener of motionListeners) listener(motion) + return { motion } + }, + onMotionChanged: (listener: (motion: MotionPreference) => void) => { + motionListeners.add(listener) + return () => motionListeners.delete(listener) + } + }, system: { openExternal: async (url: string) => { window.__canvasTest.opened.push(url) diff --git a/test/canvas/diff.ts b/test/canvas/diff.ts index a8d8939..7ec33ba 100644 --- a/test/canvas/diff.ts +++ b/test/canvas/diff.ts @@ -22,6 +22,10 @@ import { FIXTURES, LARGE_DIFF } from './fixtures' import { activePrompt, promptEntries } from '../../src/renderer/src/canvas/prompt-history' import type { Message, MessagePart } from '../../src/shared/types' import { parseMarkdown } from '../../src/renderer/src/canvas/markdown' +import { ansiLineText, parseAnsi } from '../../src/renderer/src/canvas/ansi' +import { layoutToolCard } from '../../src/renderer/src/canvas/tool-card' +import { layoutTerminalBody } from '../../src/renderer/src/canvas/terminal' +import { createStreamPublisher } from '../../src/renderer/src/lib/stream-publisher' let checks = 0 function check(name: string, run: () => void): void { @@ -649,4 +653,261 @@ check('a dragged selection retains its source rows across viewport boundaries', assert.ok(next.window!.end >= next.window!.scrollTop + 600) }) +check('Windows terminal lines survive CRLF and ANSI style changes', () => { + assert.deepEqual(parseAnsi('first\r\nsecond\r\n').map(ansiLineText), ['first', 'second', '']) + assert.deepEqual(parseAnsi('\u001b[31merror\r\u001b[0m\nnext').map(ansiLineText), [ + 'error', + 'next' + ]) + assert.deepEqual(parseAnsi('10%\r20%\r\u001b[32m100%\r').map(ansiLineText), ['100%']) +}) + +check('short bash cards do not reserve blank ANSI output space', () => { + const part: Extract = { + type: 'tool', + tool: 'bash', + state: 'done', + title: 'pwd', + output: '$ pwd\n' + '\u001b[0m\r\n'.repeat(40) + } + const builder = new Builder(metrics, theme, { value: 0 }, t) + const height = layoutToolCard( + builder, + { part, id: 'bash', view: view(), open: true, live: false, cancellable: false }, + 0, + 0, + 600 + ) + assert.ok(height < 90, `One command occupied ${height}px`) +}) + +check('long bash commands wrap completely instead of being clipped', () => { + const command = 'Copy-Item ' + '"C:\\long folder\\file.txt" '.repeat(12) + '-Force' + const part: Extract = { + type: 'tool', + tool: 'bash', + state: 'done', + input: { command }, + title: command, + output: `$ ${command}\n` + } + const builder = new Builder(metrics, theme, { value: 0 }, t) + const height = layoutToolCard( + builder, + { part, id: 'bash', view: view(), open: true, live: false, cancellable: false }, + 0, + 0, + 300 + ) + const scene = sceneOf(builder, height) + const rows = scene.blocks[0].selectable + assert.ok(rows.length > 1) + assert.ok(rows.every((row) => row.runs.every((run) => row.x + run.x + run.width <= 288))) + assert.equal( + selectionText(scene, { + startLine: 0, + startChar: 0, + endLine: rows.length - 1, + endChar: rows.at(-1)!.text.length + }), + `$ ${command}` + ) +}) + +check('terminal wrapping retains ANSI styles, tabs and graphemes', () => { + const part: Extract = { + type: 'tool', + tool: 'bash_output', + state: 'done', + output: + '\u001b[1;3;4;31;44m' + + 'red\t'.repeat(8) + + '\u001b[0m\r\n' + + '\u6f22\u{1f600}'.repeat(20) + + '\r\n[exit 1]\r\n' + } + const builder = new Builder(metrics, theme, { value: 0 }, t) + const height = layoutTerminalBody(builder, part, 'ansi', view(), 0, 0, 180) + const scene = sceneOf(builder, height) + const rows = scene.blocks[0].selectable + const runs = rows.flatMap((row) => row.runs) + assert.ok( + runs.some( + (run) => + run.color === '#f87171' && + run.background === '#60a5fa' && + run.underline && + run.font.weight === 700 && + run.font.style === 'italic' + ) + ) + assert.ok( + runs.filter((run) => run.text === '\t').every((run) => run.width > 0 && run.width <= 24) + ) + assert.ok(runs.every((run) => !/[\uD800-\uDBFF]$/.test(run.text))) + assert.equal( + selectionText(scene, { + startLine: 0, + startChar: 0, + endLine: rows.length - 1, + endChar: rows.at(-1)!.text.length + }), + 'red\t'.repeat(8) + '\n' + '\u6f22\u{1f600}'.repeat(20) + '\n[exit 1]' + ) +}) + +check('long terminal output scrolls to every wrapped row without remeasuring', () => { + const part: Extract = { + type: 'tool', + tool: 'bash_output', + state: 'done', + output: + Array.from({ length: 100 }, (_, i) => `row ${i}\t${'payload '.repeat(15)}`).join('\r\n') + + '\r\n[exit 0]\r\n' + } + const state = view() + const layout = (m = metrics): Scene => { + const builder = new Builder(m, theme, { value: 0 }, t) + return sceneOf(builder, layoutTerminalBody(builder, part, 'terminal', state, 0, 0, 200)) + } + let scene = layout() + const start = scene.blocks[0].scrollRegions[0] + assert.equal(start.h, 288) + assert.equal(start.contentWidth, start.w) + assert.ok(start.contentHeight > 2000) + state.scroll.set('terminal', { top: 999999, left: 500 }) + scene = layout({ + ...metrics, + measure: () => { + throw new Error('Rewrapped during a scroll') + } + } as unknown as TextMetrics) + const end = scene.blocks[0].scrollRegions[0] + const row = scene.blocks[0].selectable.at(-1)! + assert.equal(end.left, 0) + assert.equal(end.top, end.contentHeight - end.h) + assert.equal(row.text, '[exit 0]') + assert.ok(row.y >= end.y && row.y + row.height <= end.y + end.h) + assert.equal(hitText(scene, 20, end.y - 5, metrics), null) +}) + +check('multiline commands and terminal copies use full input without duplicates', () => { + const command = 'first-command\n second-command --argument "literal spaces"' + const part: Extract = { + type: 'tool', + tool: 'bash', + state: 'done', + title: 'first-command...', + input: { command }, + output: `$ ${command}\nok\r\n\r\n[exit 1]\r\n` + } + const builder = new Builder(metrics, theme, { value: 0 }, t) + const height = layoutTerminalBody(builder, part, 'multi', view(), 0, 0, 210) + const block = sceneOf(builder, height).blocks[0] + assert.equal( + block.scrollRegions[0].copyActions?.find((action) => action.label === 'Copy command')?.text, + command + ) + assert.equal( + block.scrollRegions[0].copyActions?.find((action) => action.label === 'Copy output')?.text, + 'ok\n[exit 1]' + ) + assert.equal( + block.selectable + .map((row) => row.text) + .join('') + .split('second-command').length, + 2 + ) +}) + +check('terminal cache updates streamed output and shrinks short completed results', () => { + const part: Extract = { + type: 'tool', + tool: 'bash', + state: 'running', + title: 'pwd' + } + const state = view() + const render = (): Scene => { + const builder = new Builder(metrics, theme, { value: 0 }, t) + return sceneOf(builder, layoutTerminalBody(builder, part, 'stream', state, 0, 0, 300)) + } + assert.equal(render().height, 37) + part.output = '$ pwd\n' + 'line\r\n'.repeat(100) + assert.equal(render().height, 289) + state.scroll.set('stream', { left: 0, top: 500 }) + part.state = 'done' + part.output = '$ pwd\nC:\\workspace\r\n' + const scene = render() + assert.equal(scene.height, 57) + assert.equal(scene.blocks[0].scrollRegions[0].top, 0) + assert.ok(scene.blocks[0].selectable.some((row) => row.text === 'C:\\workspace')) +}) + +check('stream publishing stays frame-coalesced with a non-resetting timer fallback', () => { + const original = { + raf: globalThis.requestAnimationFrame, + cancel: globalThis.cancelAnimationFrame, + timeout: globalThis.setTimeout, + clear: globalThis.clearTimeout + } + const frames = new Map() + const timers = new Map void>() + let id = 0 + globalThis.requestAnimationFrame = (cb) => { + frames.set(++id, cb) + return id + } + globalThis.cancelAnimationFrame = (id) => { + frames.delete(id) + } + globalThis.setTimeout = ((cb: () => void, delay: number) => { + assert.equal(delay, 50) + timers.set(++id, cb) + return id + }) as unknown as typeof setTimeout + globalThis.clearTimeout = ((id: number) => { + timers.delete(id) + }) as unknown as typeof clearTimeout + try { + const output: (MessagePart[] | null)[] = [] + const publish = createStreamPublisher((parts) => output.push(parts)) + const first: MessagePart[] = [{ type: 'text', text: 'a' }] + const latest: MessagePart[] = [{ type: 'text', text: 'abc' }] + publish(first) + publish(latest) + assert.equal(frames.size, 1) + assert.equal(timers.size, 1) + assert.equal(output.length, 0) + frames.values().next().value!(16) + assert.deepEqual(output, [latest]) + assert.equal(timers.size, 0) + publish(first) + const timer = timers.values().next().value! + publish(latest) + assert.equal(timers.values().next().value, timer) + timer() + assert.equal(output[1], latest) + assert.equal(frames.size, 0) + publish(first) + const cancelledFrame = frames.values().next().value! + const cancelledTimer = timers.values().next().value! + publish(null) + cancelledFrame(50) + cancelledTimer() + assert.equal(output.length, 3) + assert.equal(output[2], null) + assert.equal(frames.size + timers.size, 0) + publish(first) + publish.cancel() + assert.equal(frames.size + timers.size, 0) + } finally { + globalThis.requestAnimationFrame = original.raf + globalThis.cancelAnimationFrame = original.cancel + globalThis.setTimeout = original.timeout + globalThis.clearTimeout = original.clear + } +}) + console.log(`DIFF/CANVAS MODEL OK - ${checks} checks passed`) diff --git a/test/canvas/fixtures.ts b/test/canvas/fixtures.ts index 987847f..f6049d6 100644 --- a/test/canvas/fixtures.ts +++ b/test/canvas/fixtures.ts @@ -300,3 +300,62 @@ export const HISTORY_FIXTURES: Message[] = Array.from({ length: 80 }, (_, index) ) ] }).flat() + +export const LONG_COMMAND = + 'Copy-Item "$env:APPDATA\\roxy\\roxy.db" "$env:TEMP\\roxy-probe.db" -Force; ' + + 'node perf-probe/measure-session-switches.cjs --label "canvas startup benchmark" --output "$env:TEMP\\full-session-switch-benchmark.json"' +export const TERMINAL_FIXTURES: Message[] = [ + message('terminal-user', 'user', [{ type: 'text', text: 'Check these command results.' }], 1), + message( + 'terminal-session', + 'assistant', + [ + { + type: 'tool', + tool: 'bash', + state: 'done', + title: 'pwd', + input: { command: 'pwd' }, + output: '$ pwd\n' + '\u001b[0m\r\n'.repeat(40) + }, + { + type: 'tool', + tool: 'bash', + state: 'done', + title: LONG_COMMAND, + input: { command: LONG_COMMAND }, + output: `$ ${LONG_COMMAND}\n\u001b[32mCopied the database and saved the benchmark.\u001b[0m\r\n` + }, + { + type: 'tool', + tool: 'bash_output', + state: 'done', + title: 'bg_1', + output: + '[bg_1 exited (exit 0)]\r\n' + + Array.from( + { length: 120 }, + (_, i) => `\u001b[32mlog row ${i}\u001b[0m\t${'long output detail '.repeat(10)}` + ).join('\r\n') + + '\r\n[exit 0]\r\n' + }, + { + type: 'tool', + tool: 'task', + state: 'done', + title: 'Inspect command', + children: [ + { + type: 'tool', + tool: 'bash', + state: 'done', + title: LONG_COMMAND, + input: { command: LONG_COMMAND }, + output: `$ ${LONG_COMMAND}\nok\r\n` + } + ] + } + ], + 2 + ) +] diff --git a/test/canvas/harness.tsx b/test/canvas/harness.tsx index c4cb80d..6619602 100644 --- a/test/canvas/harness.tsx +++ b/test/canvas/harness.tsx @@ -8,10 +8,14 @@ import '../../src/renderer/src/i18n' import { CanvasTranscript } from '../../src/renderer/src/canvas/CanvasTranscript' import { AppContextMenu } from '../../src/renderer/src/components/AppContextMenu' import { DiffViewer } from '../../src/renderer/src/components/diff/DiffViewer' -import { FIXTURES, STREAMING, LARGE_DIFF, HISTORY_FIXTURES } from './fixtures' +import { FIXTURES, STREAMING, LARGE_DIFF, HISTORY_FIXTURES, TERMINAL_FIXTURES } from './fixtures' import { PerformanceHarness } from './PerformanceHarness' +import { AnimationHarness } from './AnimationHarness' +import { startMotion } from '../../src/renderer/src/lib/motion' document.documentElement.dataset.platform = 'win32' +const stopMotion = startMotion() +import.meta.hot?.dispose(stopMotion) function Harness(): JSX.Element { const [streaming, setStreaming] = useState(false) @@ -23,8 +27,9 @@ function Harness(): JSX.Element { const [composerTall, setComposerTall] = useState(false) const [longHistory, setLongHistory] = useState(false) const [addedPrompt, setAddedPrompt] = useState(false) + const [terminalCards, setTerminalCards] = useState(false) const messages = useMemo(() => { - const base = longHistory ? HISTORY_FIXTURES : FIXTURES + const base = terminalCards ? TERMINAL_FIXTURES : longHistory ? HISTORY_FIXTURES : FIXTURES return addedPrompt ? [ ...base, @@ -37,7 +42,7 @@ function Harness(): JSX.Element { } ] : base - }, [longHistory, addedPrompt]) + }, [longHistory, addedPrompt, terminalCards]) useEffect(() => { if (!loading) return const timer = setTimeout(() => setLoading(false), 180) @@ -110,6 +115,9 @@ function Harness(): JSX.Element { + {standalone ? ( @@ -119,7 +127,7 @@ function Harness(): JSX.Element { window.__canvasTest.cancelled.push(id)} onCancelTool={(id) => window.__canvasTest.cancelled.push(id)} @@ -137,6 +145,12 @@ function Harness(): JSX.Element { createRoot(document.getElementById('root')!).render( - {new URLSearchParams(location.search).has('performance') ? : } + {new URLSearchParams(location.search).has('animation') ? ( + + ) : new URLSearchParams(location.search).has('performance') ? ( + + ) : ( + + )} ) diff --git a/test/canvas/smoke.cjs b/test/canvas/smoke.cjs index f2ce63a..b079205 100644 --- a/test/canvas/smoke.cjs +++ b/test/canvas/smoke.cjs @@ -12,6 +12,8 @@ const temp = require('node:fs').mkdtempSync(path.join(os.tmpdir(), 'roxy-canvas- app.setPath('userData', temp) app.commandLine.appendSwitch('disable-renderer-backgrounding') const wait = (ms = 80) => new Promise((resolve) => setTimeout(resolve, ms)) +const settle = () => + evaluate('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))') const check = (name, value) => { assert.ok(value, name) checks++ @@ -23,6 +25,12 @@ const probe = (code) => evaluate( `(() => { const probe = window.__canvasTranscript; const scene = probe.scene(); const el = document.querySelector('[data-canvas-surface]'); ${code} })()` ) +const railCentered = () => + evaluate(`(() => { + const rail = document.querySelector('[data-prompt-history]').getBoundingClientRect(); + const viewport = document.querySelector('[data-canvas-viewport]').getBoundingClientRect(); + return Math.abs(rail.top + rail.height / 2 - viewport.top - viewport.height / 2) < 1; +})()`) let buttons = 0 async function mouse(type, x, y, button = 'left', extra = {}) { @@ -39,7 +47,7 @@ async function mouse(type, x, y, button = 'left', extra = {}) { clickCount: type === 'mouseMove' ? 0 : 1, ...extra }) - await wait() + await settle() } async function click(x, y, button = 'left') { await mouse('mouseMove', x, y) @@ -66,15 +74,36 @@ async function activate(filter) { await click(point.x, point.y) } async function key(keyCode, modifiers = []) { - keyCode = - { ArrowDown: 'Down', ArrowUp: 'Up', ArrowLeft: 'Left', ArrowRight: 'Right', Enter: 'Return' }[ - keyCode - ] ?? keyCode - win.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers }) - if (keyCode === 'Return') - win.webContents.sendInputEvent({ type: 'char', keyCode: '\r', modifiers }) - win.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers }) - await wait() + if (!win.webContents.debugger.isAttached()) win.webContents.debugger.attach('1.3') + const code = keyCode.length === 1 ? `Key${keyCode.toUpperCase()}` : keyCode + const virtualKey = + { + Enter: 13, + Escape: 27, + Home: 36, + End: 35, + ArrowDown: 40, + ArrowUp: 38, + PageDown: 34, + PageUp: 33 + }[keyCode] ?? keyCode.toUpperCase().charCodeAt(0) + const mask = modifiers.reduce( + (value, name) => value | ({ control: 2, meta: 4, shift: 8, alt: 1 }[name] ?? 0), + 0 + ) + const params = { key: keyCode, code, windowsVirtualKeyCode: virtualKey, modifiers: mask } + await win.webContents.debugger.sendCommand('Input.dispatchKeyEvent', { + type: 'rawKeyDown', + ...params + }) + if (keyCode === 'Enter') + await win.webContents.debugger.sendCommand('Input.dispatchKeyEvent', { + type: 'char', + ...params, + text: '\r' + }) + await win.webContents.debugger.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...params }) + await settle() } async function menuAction(label) { const point = await evaluate( @@ -308,6 +337,8 @@ async function run() { await probe(`return scene.height === ${height}`) ) + await terminalCards() + await activate(`r.action.type === 'toggle' && r.action.id === 'm2/5'`) await wait(1500) check( @@ -609,7 +640,10 @@ async function promptHistory() { `document.querySelectorAll('[data-prompt-id]').length===2 && !document.querySelector('[data-prompt-id="m2"]')` ) ) + check('prompt rail is vertically centered for short histories', await railCentered()) await clickDom('#tail') + // Motion defaults to On: let the previously active marker settle to its idle opacity. + await wait(180) check( 'latest prompt is active on arrival at the tail', await evaluate( @@ -739,6 +773,7 @@ async function promptHistory() { `const buttons=document.querySelectorAll('[data-prompt-id]');buttons.length>0&&buttons.length<=18` ) ) + check('long prompt rails are centered within the chat viewport', await railCentered()) check( 'long history keeps the latest marker in view', await evaluate(`!!document.querySelector('[data-prompt-id="history-user-79"][aria-current]')`) @@ -854,6 +889,7 @@ async function promptHistory() { 'narrow history rail keeps chat free of horizontal overflow', await probe('return el.scrollWidth===el.clientWidth') ) + check('prompt rail stays centered after a narrow resize', await railCentered()) check( 'narrow preview remains inside the window', await evaluate( @@ -865,9 +901,11 @@ async function promptHistory() { (await win.webContents.capturePage()).toPNG() ) if (!win.webContents.debugger.isAttached()) win.webContents.debugger.attach('1.3') + await evaluate(`window.roxy.settings.setMotion('system')`) await win.webContents.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] }) + await wait(80) check( 'rail respects reduced motion', await evaluate( @@ -875,6 +913,7 @@ async function promptHistory() { ) ) win.webContents.debugger.detach() + await evaluate(`window.roxy.settings.setMotion('on')`) await clickDom('#session') check( 'session switch dismisses stale prompt previews', @@ -888,6 +927,146 @@ async function promptHistory() { check('prompt navigation produces no renderer errors', errors.length === 0) } +async function terminalCards() { + const originalSize = win.getContentSize() + await clickDom('#terminal-cards') + await activate(`r.action.type==='toggle' && r.action.id==='terminal-session/0'`) + check( + 'one-line bash card has no empty terminal-sized panel', + await probe(` + const r=scene.blocks.flatMap(b=>b.scrollRegions).find(r=>r.id==='terminal-session/0'); + return r.h < 50 && r.h===r.contentHeight && r.contentWidth===r.w; + `) + ) + check('single-marker prompt rail is centered', await railCentered()) + await activate(`r.action.type==='toggle' && r.action.id==='terminal-session/1'`) + check( + 'full bash command wraps inside its card', + await probe(` + const r=scene.blocks.flatMap(b=>b.scrollRegions).find(r=>r.id==='terminal-session/1'); + const lines=scene.blocks.flatMap(b=>b.selectable).filter(l=>l.clip?.y===r.y); + return r.h < 150 && lines.length > 2 && lines.every(l=>l.runs.every(run=>l.x+run.x+run.width <= r.x+r.w-11)) && + lines.map(l=>l.text).join('').includes('full-session-switch-benchmark.json'); + `) + ) + check( + 'Windows bash output remains visible rather than blank', + await probe(` + return scene.blocks.flatMap(b=>b.selectable).some(l=>l.text.includes('Copied the database and saved')); + `) + ) + const command = await probe( + `return scene.blocks.flatMap(b=>b.scrollRegions).find(r=>r.id==='terminal-session/1').copyActions.find(a=>a.label==='Copy command').text` + ) + const commandPoint = await probe( + `const r=scene.blocks.flatMap(b=>b.scrollRegions).find(r=>r.id==='terminal-session/1'); const b=el.getBoundingClientRect(); return{x:b.x+r.x+30,y:b.y+r.y-el.scrollTop+15};` + ) + await click(commandPoint.x, commandPoint.y, 'right') + await menuAction('Copy command') + check( + 'copy command includes the complete unwrapped source', + await evaluate(`window.__canvasTest.copied.at(-1)===${JSON.stringify(command)}`) + ) + await click(commandPoint.x, commandPoint.y, 'right') + await menuAction('Copy output') + check( + 'copy output preserves the Windows output without ANSI escapes', + await evaluate( + `window.__canvasTest.copied.at(-1)==='Copied the database and saved the benchmark.'` + ) + ) + await fs.mkdir(path.resolve(__dirname, '../.out'), { recursive: true }) + await fs.writeFile( + path.resolve(__dirname, '../.out/bash-compact.png'), + (await win.webContents.capturePage()).toPNG() + ) + + await activate(`r.action.type==='toggle' && r.action.id==='terminal-session/2'`) + check( + 'large bash logs use a bounded vertical viewport', + await probe(` + const r=scene.blocks.flatMap(b=>b.scrollRegions).find(r=>r.id==='terminal-session/2'); + return r.h===288 && r.contentHeight>2000 && r.contentWidth===r.w && el.scrollWidth===el.clientWidth; + `) + ) + const logPoint = await probe( + `const r=scene.blocks.flatMap(b=>b.scrollRegions).find(r=>r.id==='terminal-session/2'); const b=el.getBoundingClientRect();return{x:b.x+r.x+60,y:b.y+r.y-el.scrollTop+120};` + ) + await click(logPoint.x, logPoint.y) + const outerTop = await probe('return el.scrollTop') + await win.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mouseWheel', + x: logPoint.x, + y: logPoint.y, + deltaX: 0, + deltaY: 250 + }) + await wait(150) + check( + 'bash wheel scroll stays inside the output card', + await probe(` + return scene.blocks.flatMap(b=>b.scrollRegions).find(r=>r.id==='terminal-session/2').top > 0 && Math.abs(el.scrollTop-${outerTop})<1; + `) + ) + await key('End') + check( + 'last bash output row and exit status are reachable', + await probe(` + const r=scene.blocks.flatMap(b=>b.scrollRegions).find(r=>r.id==='terminal-session/2'); + const lines=scene.blocks.flatMap(b=>b.selectable).filter(l=>l.clip?.y===r.y); + const footer=lines.at(-1); + return r.top===r.contentHeight-r.h && footer.text==='[exit 0]' && footer.y>=r.y && footer.y+footer.height<=r.y+r.h; + `) + ) + await click(logPoint.x, logPoint.y, 'right') + await menuAction('Copy output') + check( + 'copy long bash output includes both first and last rows', + await evaluate(` + const text=window.__canvasTest.copied.at(-1);text.includes('log row 0') && text.includes('log row 119') && text.endsWith('[exit 0]'); + `) + ) + const thumb = await probe( + `const r=scene.blocks.flatMap(b=>b.scrollRegions).find(r=>r.id==='terminal-session/2');const b=el.getBoundingClientRect();return{x:b.x+r.x+r.w-5,top:b.y+r.y-el.scrollTop+4,bottom:b.y+r.y-el.scrollTop+r.h-4};` + ) + await mouse('mouseDown', thumb.x, thumb.bottom) + await mouse('mouseMove', thumb.x, thumb.top) + await mouse('mouseUp', thumb.x, thumb.top) + check( + 'bash output scrollbar is draggable', + await probe( + `return scene.blocks.flatMap(b=>b.scrollRegions).find(r=>r.id==='terminal-session/2').top===0` + ) + ) + + await activate(`r.action.type==='toggle' && r.action.id==='terminal-session/3'`) + await activate(`r.action.type==='toggle' && r.action.id==='terminal-session/3/0'`) + check( + 'subagent bash commands use the same wrapping and compact layout', + await probe(` + const r=scene.blocks.flatMap(b=>b.scrollRegions).find(r=>r.id==='terminal-session/3/0'); + return r.h<160 && r.contentWidth===r.w; + `) + ) + win.setContentSize(420, 780) + await wait(150) + await target(`r.action.type==='toggle' && r.action.id==='terminal-session/1'`) + check( + 'long bash commands remain readable in a narrow window', + await probe(` + const r=scene.blocks.flatMap(b=>b.scrollRegions).find(r=>r.id==='terminal-session/1'); + return r.contentWidth===r.w && el.scrollWidth===el.clientWidth && scene.blocks.flatMap(b=>b.selectable).filter(l=>l.clip?.y===r.y).every(l=>l.runs.every(run=>l.x+run.x+run.width<=r.x+r.w-11)); + `) + ) + check('centered prompt rail follows composer height', await railCentered()) + await fs.writeFile( + path.resolve(__dirname, '../.out/bash-narrow.png'), + (await win.webContents.capturePage()).toPNG() + ) + win.setContentSize(...originalSize) + await clickDom('#terminal-cards') +} + app.whenReady().then(async () => { const watchdog = setTimeout(() => { console.error('Canvas smoke timed out') diff --git a/test/canvas/vite.config.mjs b/test/canvas/vite.config.mjs index 9344b70..846bf81 100644 --- a/test/canvas/vite.config.mjs +++ b/test/canvas/vite.config.mjs @@ -7,11 +7,13 @@ import tailwindcss from '@tailwindcss/vite' export default defineConfig({ root: resolve(import.meta.dirname, '.'), resolve: { + dedupe: ['react', 'react-dom'], alias: { '@shared': resolve(import.meta.dirname, '../../src/shared'), '@renderer': resolve(import.meta.dirname, '../../src/renderer/src') } }, + optimizeDeps: { include: ['zustand'] }, plugins: [react(), tailwindcss()], server: { port: 3114, strictPort: true } }) diff --git a/test/shared.ts b/test/shared.ts index d1ffca1..b8cee92 100644 --- a/test/shared.ts +++ b/test/shared.ts @@ -42,6 +42,7 @@ import { upstreamFor } from '../src/shared/cliproxy' import { modelLabel, pickDefaultModel } from '../src/shared/models' +import { DEFAULT_MOTION, normalizeMotion, reduceMotion } from '../src/shared/motion' import { BUILT_IN_THEMES, DEFAULT_THEME_ID, @@ -352,6 +353,25 @@ function check(name: string, cond: boolean, detail = ''): void { console.log('shared catalogs\n') +check('motion: normal animation is the default', DEFAULT_MOTION === 'on') +check( + 'motion: missing and unknown values fall back to On', + [undefined, null, '', 'invalid', true].every((value) => normalizeMotion(value) === 'on') +) +check( + 'motion: explicit reduced mode survives normalization', + normalizeMotion('reduced') === 'reduced' +) +check('motion: On overrides OS reduction without altering the OS', !reduceMotion('on', true)) +check( + 'motion: Reduced applies regardless of OS preference', + reduceMotion('reduced', false) && reduceMotion('reduced', true) +) +check( + 'motion: Follow system tracks both OS states', + !reduceMotion('system', false) && reduceMotion('system', true) +) + // ---- tools ---- check('tools non-empty', TOOLS.length > 0) check('tool ids unique', new Set(TOOLS.map((t) => t.id)).size === TOOLS.length) diff --git a/test/smoke.ts b/test/smoke.ts index 43f1545..dec83f1 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -196,6 +196,15 @@ async function main(): Promise { // Auto-workstream defaults ON and is stored only when disabled, so existing // installs are opted in without a migration. check('auto-workstream defaults on', repo.getSettings().autoWorkstream === true) + check('motion defaults on', repo.getSettings().motion === 'on') + repo.setMotion('reduced') + check('reduced motion persists', repo.getSettings().motion === 'reduced') + repo.setMotion('system') + check('system motion persists', repo.getSettings().motion === 'system') + repo.setMotion('on') + check('normal motion restores the default', repo.getSettings().motion === 'on') + repo.setMotion('unknown' as never) + check('unknown motion values fall back to On', repo.getSettings().motion === 'on') repo.setAutoWorkstream(false) check('setAutoWorkstream(false) persists', repo.getSettings().autoWorkstream === false) repo.setAutoWorkstream(true)