Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
8 changes: 8 additions & 0 deletions src/main/db/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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')
Expand Down
13 changes: 12 additions & 1 deletion src/main/ipc/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
)
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand Down
33 changes: 17 additions & 16 deletions src/renderer/src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/browser/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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.
Expand Down
28 changes: 21 additions & 7 deletions src/renderer/src/canvas/CanvasSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<object>())
const debug = useRef<CanvasProbe['debug']>({
frames: 0,
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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
Expand All @@ -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 => {
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/canvas/PromptHistoryRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 13 additions & 11 deletions src/renderer/src/canvas/ansi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
}

Expand Down
11 changes: 5 additions & 6 deletions src/renderer/src/canvas/prompt-history.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.prompt-history {
position: absolute;
top: 16px;
top: 50%;
transform: translateY(-50%);
right: 12px;
width: 28px;
z-index: 20;
Expand Down Expand Up @@ -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;
}
16 changes: 14 additions & 2 deletions src/renderer/src/canvas/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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':
Expand Down
Loading
Loading