Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface SettingsToggleProps {
className?: string
/** Whether the toggle is inside a card (affects padding) */
inCard?: boolean
/** Optional test id forwarded to the underlying switch (for e2e) */
testId?: string
}

/**
Expand All @@ -48,6 +50,7 @@ export function SettingsToggle({
disabled,
className,
inCard = true,
testId,
}: SettingsToggleProps) {
const id = React.useId()

Expand All @@ -73,6 +76,7 @@ export function SettingsToggle({
onCheckedChange={onCheckedChange}
disabled={disabled}
data-layout="settings-control"
data-testid={testId}
className="ml-4 shrink-0"
/>
</div>
Expand Down
82 changes: 82 additions & 0 deletions apps/electron/src/renderer/context/ReduceMotionContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* ReduceMotionContext
*
* App-wide "Reduce motion" accessibility preference.
*
* When enabled it:
* - renders the app inside `<MotionConfig reducedMotion="always">` so every
* `motion/react` component short-circuits its animations (MotionConfig
* propagates through React context to all `motion` components in the tree,
* including those in `packages/ui`);
* - sets `data-reduce-motion="true"` on `<html>` so the global CSS guard in
* `index.css` can collapse plain CSS animations/transitions too.
*
* When disabled it uses `reducedMotion="user"`, which still honors the OS
* `prefers-reduced-motion` setting — a better default than always animating.
*
* The preference is persisted in `localStorage` (renderer-only, no backend),
* mirroring the other lightweight UI prefs in `lib/local-storage.ts`.
*/

import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from 'react'
import { MotionConfig } from 'motion/react'
import * as storage from '@/lib/local-storage'

interface ReduceMotionContextType {
reduceMotion: boolean
setReduceMotion: (value: boolean) => void
}

const ReduceMotionContext = createContext<ReduceMotionContextType | null>(null)

const REDUCE_MOTION_ATTR = 'data-reduce-motion'

/** Reflect the preference onto <html> so the global CSS guard can react. */
function applyReduceMotionAttribute(enabled: boolean): void {
const root = document.documentElement
if (enabled) {
root.setAttribute(REDUCE_MOTION_ATTR, 'true')
} else {
root.removeAttribute(REDUCE_MOTION_ATTR)
}
}

export function ReduceMotionProvider({ children }: { children: ReactNode }) {
const [reduceMotion, setReduceMotionState] = useState<boolean>(() =>
storage.get<boolean>(storage.KEYS.reduceMotion, false),
)

// Keep the DOM attribute in sync (also covers the initial value on mount).
useEffect(() => {
applyReduceMotionAttribute(reduceMotion)
}, [reduceMotion])

const setReduceMotion = useCallback((value: boolean) => {
setReduceMotionState(value)
storage.set(storage.KEYS.reduceMotion, value)
applyReduceMotionAttribute(value)
}, [])

return (
<ReduceMotionContext.Provider value={{ reduceMotion, setReduceMotion }}>
<MotionConfig reducedMotion={reduceMotion ? 'always' : 'user'}>
{children}
</MotionConfig>
</ReduceMotionContext.Provider>
)
}

export function useReduceMotion(): ReduceMotionContextType {
const ctx = useContext(ReduceMotionContext)
if (!ctx) {
throw new Error('useReduceMotion must be used within a ReduceMotionProvider')
}
return ctx
}
15 changes: 15 additions & 0 deletions apps/electron/src/renderer/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1371,3 +1371,18 @@ html.dark[data-scenic] .fullscreen-overlay-background {
min-height: 31px;
padding: 6px 8px;
}

/* Reduce motion: when the user enables "Reduce motion" in Appearance settings,
near-instantly complete CSS animations/transitions across the app. This
complements `motion/react`'s MotionConfig reducedMotion="always" (which handles
`motion` components) by covering plain CSS effects too. */
[data-reduce-motion='true'] *,
[data-reduce-motion='true'] *::before,
[data-reduce-motion='true'] *::after {
animation-duration: 0.001ms !important;
animation-delay: 0ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
transition-delay: 0ms !important;
scroll-behavior: auto !important;
}
1 change: 1 addition & 0 deletions apps/electron/src/renderer/lib/local-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const KEYS = {

// Appearance
showConnectionIcons: 'show-connection-icons',
reduceMotion: 'reduce-motion', // Minimize animations/transitions app-wide

// What's New
whatsNewLastSeenVersion: 'whats-new-last-seen-version',
Expand Down
9 changes: 6 additions & 3 deletions apps/electron/src/renderer/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { captureConsoleIntegration } from '@sentry/react'
import { Provider as JotaiProvider, useAtomValue } from 'jotai'
import App from './App'
import { ThemeProvider } from './context/ThemeContext'
import { ReduceMotionProvider } from './context/ReduceMotionContext'
import { windowWorkspaceIdAtom } from './atoms/sessions'
import { Toaster } from '@/components/ui/sonner'
import { PetWindowController } from '@/components/pet/PetWindowController'
Expand Down Expand Up @@ -106,9 +107,11 @@ function Root() {

return (
<ThemeProvider activeWorkspaceId={workspaceId}>
<App />
<Toaster />
<PetWindowController />
<ReduceMotionProvider>
<App />
<Toaster />
<PetWindowController />
</ReduceMotionProvider>
</ThemeProvider>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { HeaderMenu } from '@/components/ui/HeaderMenu'
import { EditPopover, EditButton, getEditConfig } from '@/components/ui/EditPopover'
import { useTheme } from '@/context/ThemeContext'
import { useReduceMotion } from '@/context/ReduceMotionContext'
import { useAppShellContext } from '@/context/AppShellContext'
import { routes } from '@/lib/navigate'
import { FolderOpen, Monitor, RefreshCw, Sun, Moon } from 'lucide-react'
Expand Down Expand Up @@ -139,6 +140,9 @@ export default function AppearanceSettingsPage() {
await window.electronAPI?.setRichToolDescriptions?.(checked)
}, [])

// Reduce motion toggle (renderer-only preference, persisted in localStorage)
const { reduceMotion, setReduceMotion } = useReduceMotion()

// Pet companion settings + custom pets (synced via shared Jotai atoms)
const {
pets,
Expand Down Expand Up @@ -376,6 +380,13 @@ export default function AppearanceSettingsPage() {
checked={richToolDescriptions}
onCheckedChange={handleRichToolDescriptionsChange}
/>
<SettingsToggle
label={t("settings.appearance.reduceMotion")}
description={t("settings.appearance.reduceMotionDesc")}
checked={reduceMotion}
onCheckedChange={setReduceMotion}
testId="reduce-motion-toggle"
/>
</SettingsCard>
</SettingsSection>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ function SettingsItemRow({ item, isSelected, isFirst, onSelect }: SettingsItemRo
}

return (
<div className="settings-item" data-selected={isSelected || undefined}>
<div
className="settings-item"
data-selected={isSelected || undefined}
data-testid={`settings-nav-${item.id}`}
>
{/* Separator - only show if not first */}
{!isFirst && (
<div className="settings-separator pl-12 pr-4">
Expand Down
5 changes: 4 additions & 1 deletion docs/loop/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ log, not the system of record.

| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| thinking-level-picker | Thinking-level (reasoning effort) picker in the chat composer | Claude Code Desktop effort menu (⌘⇧E) + OpenWork's own model picker | frontend-only | pr-open | [#44](https://github.com/modelstudioai/openwork/issues/44) | [#45](https://github.com/modelstudioai/openwork/pull/45) | loop/thinking-level-picker | 2026-07-02 | `thinkingLevel`/`onThinkingLevelChange` already plumbed to `FreeFormInput`; only the UI trigger was missing. Reuses `thinking.*` + `settings.ai.thinking` i18n keys (zero new keys). typecheck/`bun test` zero-delta vs main (3578 pass/56 fail on both); renderer build ✅. **CDP could not run locally**: this sandbox's egress policy 403s the Electron binary download and the `libsignal` GitHub dep (WhatsApp worker), so the app can't be built/launched here — assertion included for CI/reviewer. |
| reduce-motion | "Reduce motion" accessibility setting in Appearance | Claude desktop / macOS / Windows reduce-motion + `prefers-reduced-motion` | frontend-only | pr-open | [#50](https://github.com/modelstudioai/openwork/issues/50) | [#51](https://github.com/modelstudioai/openwork/pull/51) | loop/reduce-motion | 2026-07-03 | Renderer-only pref (localStorage) applied app-wide via `<MotionConfig reducedMotion>` + `data-reduce-motion` on `<html>` + global CSS guard. Off ⇒ `reducedMotion="user"` (still honors OS). New `ReduceMotionProvider` in `main.tsx`; toggle in Appearance→Interface; 2 new i18n keys ×7 locales. typecheck/`bun test` zero-delta vs main (56-failure set byte-identical); renderer build ✅; i18n parity ✅. CDP assertion included; **could not run locally** (egress 403s Electron binary download). |
| composer-expand | Expand / collapse (maximize) toggle for the chat composer | Claude/ChatGPT/Codex desktop composer maximize | frontend-only | pr-open | [#48](https://github.com/modelstudioai/openwork/issues/48) | [#49](https://github.com/modelstudioai/openwork/pull/49) | loop/composer-expand | 2026-07-03 | Opened by a prior run. Adds `isComposerExpanded` toggle in `FreeFormInput`; 2 new i18n keys. Awaiting review. |
| scroll-to-bottom | "Jump to latest" (scroll-to-bottom) button in the chat transcript | Claude Code / ChatGPT / Codex desktop | frontend-only | pr-open | [#46](https://github.com/modelstudioai/openwork/issues/46) | [#47](https://github.com/modelstudioai/openwork/pull/47) | loop/scroll-to-bottom | 2026-07-02 | Opened by a prior run. Floating jump button in `ChatDisplay` + `seed()` harness hook. Awaiting review. |
| thinking-level-picker | Thinking-level (reasoning effort) picker in the chat composer | Claude Code Desktop effort menu (⌘⇧E) + OpenWork's own model picker | frontend-only | merged | [#44](https://github.com/modelstudioai/openwork/issues/44) | [#45](https://github.com/modelstudioai/openwork/pull/45) | loop/thinking-level-picker | 2026-07-03 | **Merged** into `main` (2026-07-02). `thinkingLevel`/`onThinkingLevelChange` already plumbed to `FreeFormInput`; only the UI trigger was missing. Reuses `thinking.*` + `settings.ai.thinking` i18n keys (zero new keys). |
| command-palette | Global command palette (⌘K/Ctrl+K) to search & run any action | Claude Code Desktop ⌘K / VS Code & Codex ⌘⇧P / Linear ⌘K | frontend-only | merged | [#41](https://github.com/modelstudioai/openwork/issues/41) | [#42](https://github.com/modelstudioai/openwork/pull/42) | loop/command-palette | 2026-07-02 | Merged into `main`. Reuses action registry `execute()` + cmdk primitives; zero new i18n keys. CDP e2e 2/2 pass. typecheck/test +0 vs main. |
| settings-search | Searchable/filterable settings navigation | Claude Code Desktop / VS Code / Codex desktop settings search | frontend-only | merged | [#39](https://github.com/modelstudioai/openwork/issues/39) | [#40](https://github.com/modelstudioai/openwork/pull/40) | loop/settings-search | 2026-07-01 | Merged into `main`. Filters `SettingsNavigator` by title+description; reuses `common.search`/`common.noResultsFound` (no new locale keys). Also hardened `e2e/app.ts` teardown (per-launch profile dir + setsid process-group kill) so multiple CDP assertions run under headless xvfb. |
99 changes: 99 additions & 0 deletions e2e/assertions/reduce-motion.assert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Feature assertion: the "Reduce motion" toggle in Settings → Appearance
* actually applies and persists an app-wide reduced-motion preference.
*
* Drives the real UI over CDP entirely in the draft/no-session state (no seeded
* conversation, no backend connection): opens Settings → Appearance, flips the
* toggle, and asserts the observable effects — the switch state, the
* `data-reduce-motion` attribute on <html>, and the persisted localStorage value.
* Toggling twice proves it both applies and reverts, not merely renders.
*/

import type { Assertion } from '../runner';

const SETTINGS_NAV = '[data-testid="nav:settings"]';
const APPEARANCE_NAV = '[data-testid="settings-nav-appearance"]';
const TOGGLE = '[data-testid="reduce-motion-toggle"]';
const STORAGE_KEY = 'craft-reduce-motion';

/** Read the toggle's aria-checked ("true" | "false" | null). */
function ariaCheckedExpr(): string {
return `(() => {
const el = document.querySelector(${JSON.stringify(TOGGLE)});
return el ? el.getAttribute('aria-checked') : null;
})()`;
}

/** True when <html> carries the reduce-motion marker attribute. */
function htmlMarkedExpr(): string {
return `document.documentElement.getAttribute('data-reduce-motion') === 'true'`;
}

/** The persisted localStorage value for the preference. */
function storedValueExpr(): string {
return `window.localStorage.getItem(${JSON.stringify(STORAGE_KEY)})`;
}

const assertion: Assertion = {
name: 'reduce-motion toggle applies and persists an app-wide preference',
async run(app) {
const { session } = app;

// App fully mounted.
await session.waitForFunction(
'!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0',
{ timeoutMs: 30000, message: 'app did not mount' },
);

// Open Settings → Appearance (real user path).
await session.click(SETTINGS_NAV, { timeoutMs: 15000 });
await session.click(APPEARANCE_NAV, { timeoutMs: 15000 });

// The toggle is the feature under test — its presence is the first signal.
await session.waitForSelector(TOGGLE, {
timeoutMs: 15000,
message: 'reduce-motion toggle did not render',
});

// Initial state: off, no marker on <html>, not persisted true.
const initialChecked = await session.evaluate<string | null>(ariaCheckedExpr());
if (initialChecked !== 'false') {
throw new Error(`expected toggle off initially, saw aria-checked=${initialChecked}`);
}
if (await session.evaluate<boolean>(htmlMarkedExpr())) {
throw new Error('expected no data-reduce-motion attribute before enabling');
}

// Enable → toggle on, <html> marked, persisted true.
await session.click(TOGGLE);
await session.waitForFunction(
`${ariaCheckedExpr()} === 'true'`,
{ timeoutMs: 5000, message: 'toggle did not switch on' },
);
await session.waitForFunction(htmlMarkedExpr(), {
timeoutMs: 5000,
message: 'data-reduce-motion was not applied to <html> when enabled',
});
const storedOn = await session.evaluate<string | null>(storedValueExpr());
if (storedOn !== 'true') {
throw new Error(`expected persisted "true" after enabling, saw ${JSON.stringify(storedOn)}`);
}

// Disable → toggle off, marker removed, persisted false.
await session.click(TOGGLE);
await session.waitForFunction(
`${ariaCheckedExpr()} === 'false'`,
{ timeoutMs: 5000, message: 'toggle did not switch off' },
);
await session.waitForFunction(`!(${htmlMarkedExpr()})`, {
timeoutMs: 5000,
message: 'data-reduce-motion was not removed from <html> when disabled',
});
const storedOff = await session.evaluate<string | null>(storedValueExpr());
if (storedOff !== 'false') {
throw new Error(`expected persisted "false" after disabling, saw ${JSON.stringify(storedOff)}`);
}
},
};

export default assertion;
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,8 @@
"settings.appearance.mode": "Modus",
"settings.appearance.noToolIcons": "Keine Werkzeugsymbol-Zuordnungen gefunden",
"settings.appearance.richToolDescriptions": "Ausführliche Werkzeugbeschreibungen",
"settings.appearance.reduceMotion": "Bewegung reduzieren",
"settings.appearance.reduceMotionDesc": "Animationen und Übergänge in der gesamten App minimieren.",
"settings.appearance.pet": "Begleiter",
"settings.appearance.petDesc": "Ein Begleiter, der auf die Aktivität des Agents reagiert.",
"settings.appearance.petEnabled": "Begleiter anzeigen",
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,8 @@
"settings.appearance.mode": "Mode",
"settings.appearance.noToolIcons": "No tool icon mappings found",
"settings.appearance.richToolDescriptions": "Rich tool descriptions",
"settings.appearance.reduceMotion": "Reduce motion",
"settings.appearance.reduceMotionDesc": "Minimize animations and transitions throughout the app.",
"settings.appearance.pet": "Pet",
"settings.appearance.petDesc": "A companion that reacts to what the agent is doing.",
"settings.appearance.petEnabled": "Show pet companion",
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,8 @@
"settings.appearance.mode": "Modo",
"settings.appearance.noToolIcons": "No se encontraron asignaciones de iconos de herramientas",
"settings.appearance.richToolDescriptions": "Descripciones detalladas de herramientas",
"settings.appearance.reduceMotion": "Reducir movimiento",
"settings.appearance.reduceMotionDesc": "Minimiza las animaciones y transiciones en toda la aplicación.",
"settings.appearance.pet": "Mascota",
"settings.appearance.petDesc": "Un compañero que reacciona a lo que hace el agente.",
"settings.appearance.petEnabled": "Mostrar mascota",
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/locales/hu.json
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,8 @@
"settings.appearance.mode": "Mód",
"settings.appearance.noToolIcons": "Nem található eszközikon-hozzárendelés",
"settings.appearance.richToolDescriptions": "Részletes eszközleírások",
"settings.appearance.reduceMotion": "Mozgás csökkentése",
"settings.appearance.reduceMotionDesc": "Az animációk és átmenetek minimalizálása az egész alkalmazásban.",
"settings.appearance.pet": "Kabala",
"settings.appearance.petDesc": "Egy társ, aki reagál arra, amit az ügynök csinál.",
"settings.appearance.petEnabled": "Kabala megjelenítése",
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,8 @@
"settings.appearance.mode": "モード",
"settings.appearance.noToolIcons": "ツールアイコンのマッピングが見つかりません",
"settings.appearance.richToolDescriptions": "リッチなツール説明",
"settings.appearance.reduceMotion": "モーションを減らす",
"settings.appearance.reduceMotionDesc": "アプリ全体のアニメーションとトランジションを最小限にします。",
"settings.appearance.pet": "ペット",
"settings.appearance.petDesc": "エージェントの動きに反応するコンパニオン。",
"settings.appearance.petEnabled": "ペットを表示",
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/locales/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,8 @@
"settings.appearance.mode": "Tryb",
"settings.appearance.noToolIcons": "Nie znaleziono mapowań ikon narzędzi",
"settings.appearance.richToolDescriptions": "Rozbudowane opisy narzędzi",
"settings.appearance.reduceMotion": "Ogranicz ruch",
"settings.appearance.reduceMotionDesc": "Ogranicz animacje i przejścia w całej aplikacji.",
"settings.appearance.pet": "Maskotka",
"settings.appearance.petDesc": "Towarzysz reagujący na to, co robi agent.",
"settings.appearance.petEnabled": "Pokaż maskotkę",
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/locales/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,8 @@
"settings.appearance.mode": "模式",
"settings.appearance.noToolIcons": "未找到工具图标映射",
"settings.appearance.richToolDescriptions": "丰富的工具描述",
"settings.appearance.reduceMotion": "减少动态效果",
"settings.appearance.reduceMotionDesc": "在整个应用中尽量减少动画和过渡效果。",
"settings.appearance.pet": "宠物",
"settings.appearance.petDesc": "一个会根据 agent 当前状态做出反应的小伙伴。",
"settings.appearance.petEnabled": "显示宠物伙伴",
Expand Down