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
4 changes: 4 additions & 0 deletions apps/electron/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { OnboardingWizard, ReauthScreen } from '@/components/onboarding'
import { WorkspacePicker } from '@/components/workspace'
import { ResetConfirmationDialog } from '@/components/ResetConfirmationDialog'
import { CommandPalette } from '@/components/CommandPalette'
import { ZoomHotkeys } from '@/components/ZoomHotkeys'
import { SplashScreen } from '@/components/SplashScreen'
import { TooltipProvider } from '@craft-agent/ui'
import { FocusProvider } from '@/context/FocusContext'
Expand Down Expand Up @@ -2500,6 +2501,9 @@ export default function App() {
{/* Global command palette (⌘K / Ctrl+K) — search and run any action */}
<CommandPalette />

{/* Interface zoom shortcuts (⌘+ / ⌘- / ⌘0) wired to the action registry */}
<ZoomHotkeys />

{/* Splash screen overlay - fades out when fully ready */}
{showSplash && (
<SplashScreen
Expand Down
3 changes: 3 additions & 0 deletions apps/electron/src/renderer/actions/action-i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export const ACTION_LABEL_KEYS: Partial<Record<ActionId, string>> = {
'nav.goForwardAlt': 'shortcuts.action.goForward',
'view.toggleSidebar': 'shortcuts.action.toggleSidebar',
'view.toggleFocusMode': 'shortcuts.action.toggleFocusMode',
'view.zoomIn': 'shortcuts.action.zoomIn',
'view.zoomOut': 'shortcuts.action.zoomOut',
'view.zoomReset': 'shortcuts.action.zoomReset',
'navigator.selectAll': 'shortcuts.action.selectAll',
'navigator.clearSelection': 'shortcuts.action.clearSelection',
'panel.focusNext': 'shortcuts.action.focusNextPanel',
Expand Down
21 changes: 21 additions & 0 deletions apps/electron/src/renderer/actions/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,27 @@ export const actions = {
defaultHotkey: 'mod+.',
category: 'View',
},
'view.zoomIn': {
id: 'view.zoomIn',
label: 'Zoom In',
description: 'Scale the whole interface up',
defaultHotkey: 'mod+=',
category: 'View',
},
'view.zoomOut': {
id: 'view.zoomOut',
label: 'Zoom Out',
description: 'Scale the whole interface down',
defaultHotkey: 'mod+-',
category: 'View',
},
'view.zoomReset': {
id: 'view.zoomReset',
label: 'Reset Zoom',
description: 'Reset the interface zoom to 100%',
defaultHotkey: 'mod+0',
category: 'View',
},

// ═══════════════════════════════════════════
// Navigator (scoped — active entity list in middle panel)
Expand Down
23 changes: 23 additions & 0 deletions apps/electron/src/renderer/components/ZoomHotkeys.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* ZoomHotkeys
*
* Bridges the interface-zoom preference to the action registry so the standard
* desktop zoom shortcuts (⌘+ / ⌘- / ⌘0) and their Command Palette entries drive
* `ZoomProvider`. Renders nothing — it only registers handlers.
*
* Must be mounted inside both `ZoomProvider` (for `useZoom`) and
* `ActionRegistryProvider` (for `useAction`).
*/

import { useZoom } from '@/context/ZoomContext'
import { useAction } from '@/actions'

export function ZoomHotkeys() {
const { zoomIn, zoomOut, resetZoom } = useZoom()

useAction('view.zoomIn', zoomIn)
useAction('view.zoomOut', zoomOut)
useAction('view.zoomReset', resetZoom)

return null
}
140 changes: 140 additions & 0 deletions apps/electron/src/renderer/context/ZoomContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* ZoomContext
*
* App-wide "interface zoom" preference — scales the whole renderer (sidebar,
* navigator, chat, settings, dialogs) up or down, like the ⌘+ / ⌘- / ⌘0 zoom
* in Claude Desktop, VS Code, and other desktop apps.
*
* The zoom factor is one of a fixed set of Chrome-like steps and is applied by
* setting `document.documentElement.style.zoom` — a Chromium-native property
* that scales the entire tree, including portalled dialogs rendered into
* `<body>`. At 100% the inline style is cleared so the DOM stays clean.
*
* The preference is persisted in `localStorage` (renderer-only, no backend),
* mirroring the other lightweight UI prefs in `lib/local-storage.ts` and the
* merged `ReduceMotionProvider`.
*/

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

/** Discrete zoom steps (factors), mirroring a browser's zoom ladder. */
export const ZOOM_STEPS: readonly number[] = [0.5, 0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2]

/** The neutral / "actual size" factor. */
export const DEFAULT_ZOOM = 1

interface ZoomContextType {
/** Current zoom factor (e.g. 1 = 100%, 1.1 = 110%). */
zoom: number
/** Whole-number percentage for display (e.g. 110). */
zoomPercent: number
/** Step up to the next larger factor (no-op at the max). */
zoomIn: () => void
/** Step down to the next smaller factor (no-op at the min). */
zoomOut: () => void
/** Return to 100%. */
resetZoom: () => void
/** Whether a larger step is available. */
canZoomIn: boolean
/** Whether a smaller step is available. */
canZoomOut: boolean
}

const ZoomContext = createContext<ZoomContextType | null>(null)

/** Clamp an arbitrary stored value onto the nearest valid step. */
function normalizeZoom(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_ZOOM
let nearest = ZOOM_STEPS[0]
let bestDelta = Math.abs(value - nearest)
for (const step of ZOOM_STEPS) {
const delta = Math.abs(value - step)
if (delta < bestDelta) {
nearest = step
bestDelta = delta
}
}
return nearest
}

/** Reflect the factor onto <html> so the whole renderer scales. */
function applyZoom(factor: number): void {
const root = document.documentElement
if (factor === DEFAULT_ZOOM) {
root.style.removeProperty('zoom')
} else {
root.style.zoom = String(factor)
}
}

export function ZoomProvider({ children }: { children: ReactNode }) {
const [zoom, setZoomState] = useState<number>(() =>
normalizeZoom(storage.get<number>(storage.KEYS.zoomLevel, DEFAULT_ZOOM)),
)

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

const setZoom = useCallback((factor: number) => {
const next = normalizeZoom(factor)
setZoomState(next)
storage.set(storage.KEYS.zoomLevel, next)
applyZoom(next)
}, [])

const zoomIn = useCallback(() => {
setZoomState((current) => {
const idx = ZOOM_STEPS.indexOf(normalizeZoom(current))
const next = ZOOM_STEPS[Math.min(idx + 1, ZOOM_STEPS.length - 1)]
storage.set(storage.KEYS.zoomLevel, next)
applyZoom(next)
return next
})
}, [])

const zoomOut = useCallback(() => {
setZoomState((current) => {
const idx = ZOOM_STEPS.indexOf(normalizeZoom(current))
const next = ZOOM_STEPS[Math.max(idx - 1, 0)]
storage.set(storage.KEYS.zoomLevel, next)
applyZoom(next)
return next
})
}, [])

const resetZoom = useCallback(() => setZoom(DEFAULT_ZOOM), [setZoom])

const value = useMemo<ZoomContextType>(() => {
const idx = ZOOM_STEPS.indexOf(zoom)
return {
zoom,
zoomPercent: Math.round(zoom * 100),
zoomIn,
zoomOut,
resetZoom,
canZoomIn: idx < ZOOM_STEPS.length - 1,
canZoomOut: idx > 0,
}
}, [zoom, zoomIn, zoomOut, resetZoom])

return <ZoomContext.Provider value={value}>{children}</ZoomContext.Provider>
}

export function useZoom(): ZoomContextType {
const ctx = useContext(ZoomContext)
if (!ctx) {
throw new Error('useZoom must be used within a ZoomProvider')
}
return ctx
}
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 @@ -53,6 +53,7 @@ export const KEYS = {
// Appearance
showConnectionIcons: 'show-connection-icons',
reduceMotion: 'reduce-motion', // Minimize animations/transitions app-wide
zoomLevel: 'zoom-level', // Interface zoom factor (⌘+/⌘-/⌘0), scales the whole renderer

// 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 @@ -7,6 +7,7 @@ import { Provider as JotaiProvider, useAtomValue } from 'jotai'
import App from './App'
import { ThemeProvider } from './context/ThemeContext'
import { ReduceMotionProvider } from './context/ReduceMotionContext'
import { ZoomProvider } from './context/ZoomContext'
import { windowWorkspaceIdAtom } from './atoms/sessions'
import { Toaster } from '@/components/ui/sonner'
import { PetWindowController } from '@/components/pet/PetWindowController'
Expand Down Expand Up @@ -108,9 +109,11 @@ function Root() {
return (
<ThemeProvider activeWorkspaceId={workspaceId}>
<ReduceMotionProvider>
<App />
<Toaster />
<PetWindowController />
<ZoomProvider>
<App />
<Toaster />
<PetWindowController />
</ZoomProvider>
</ReduceMotionProvider>
</ThemeProvider>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ 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 { useZoom } from '@/context/ZoomContext'
import { Button } from '@/components/ui/button'
import { useAppShellContext } from '@/context/AppShellContext'
import { routes } from '@/lib/navigate'
import { FolderOpen, Monitor, RefreshCw, Sun, Moon } from 'lucide-react'
import { FolderOpen, Monitor, RefreshCw, Sun, Moon, Minus, Plus, RotateCcw } from 'lucide-react'
import type { DetailsPageMeta } from '@/lib/navigation-registry'
import type { ToolIconMapping } from '../../../shared/types'

Expand Down Expand Up @@ -142,6 +144,7 @@ export default function AppearanceSettingsPage() {

// Reduce motion toggle (renderer-only preference, persisted in localStorage)
const { reduceMotion, setReduceMotion } = useReduceMotion()
const { zoomPercent, zoomIn, zoomOut, resetZoom, canZoomIn, canZoomOut } = useZoom()

// Pet companion settings + custom pets (synced via shared Jotai atoms)
const {
Expand Down Expand Up @@ -387,6 +390,58 @@ export default function AppearanceSettingsPage() {
onCheckedChange={setReduceMotion}
testId="reduce-motion-toggle"
/>
<SettingsRow
label={t("settings.appearance.zoom")}
description={t("settings.appearance.zoomDesc")}
>
<div
className="flex items-center gap-1"
data-testid="zoom-control"
>
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={t("shortcuts.action.zoomOut")}
title={t("shortcuts.action.zoomOut")}
disabled={!canZoomOut}
onClick={zoomOut}
data-testid="zoom-out"
>
<Minus className="size-4" />
</Button>
<span
className="min-w-[3.25rem] text-center text-[13px] tabular-nums text-foreground/80 select-none"
data-testid="zoom-value"
>
{zoomPercent}%
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={t("shortcuts.action.zoomIn")}
title={t("shortcuts.action.zoomIn")}
disabled={!canZoomIn}
onClick={zoomIn}
data-testid="zoom-in"
>
<Plus className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 ml-1"
aria-label={t("shortcuts.action.zoomReset")}
title={t("shortcuts.action.zoomReset")}
disabled={zoomPercent === 100}
onClick={resetZoom}
data-testid="zoom-reset"
>
<RotateCcw className="size-3.5" />
</Button>
</div>
</SettingsRow>
</SettingsCard>
</SettingsSection>

Expand Down
11 changes: 10 additions & 1 deletion docs/loop/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,16 @@ log, not the system of record.

| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 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). |
| interface-zoom | Interface zoom (⌘+/⌘-/⌘0) to scale the whole app | Claude Desktop / VS Code / Codex desktop View→Zoom In/Out/Actual Size | frontend-only | pr-open | [#68](https://github.com/modelstudioai/openwork/issues/68) | [#69](https://github.com/modelstudioai/openwork/pull/69) | loop/interface-zoom | 2026-07-08 | New `ZoomProvider` (mirrors `ReduceMotionProvider`) scales whole renderer via `document.documentElement.style.zoom`, discrete 50–200% steps, persisted in localStorage (`craft-zoom-level`). 3 View actions `view.zoomIn/Out/Reset` (`mod+=`/`mod+-`/`mod+0`) → auto in Command Palette + Shortcuts ref, wired via `ZoomHotkeys` bridge. Stepper in Appearance→Interface. 5 i18n keys ×7 locales. typecheck/`bun test` zero-delta vs main (56-failure set byte-identical); renderer build ✅; lint 0 errors; i18n parity ✅ (1549 keys). CDP assertion included; **could not run locally** (org egress 403 on Electron binary download — same block as #51). Distinct from chat-text-size (#64)/conversation-width (#62): scales entire UI, not just chat text. |
| increase-contrast | "Increase contrast" accessibility setting in Appearance | Claude / macOS / Windows increase-contrast | frontend-only | pr-open | [#66](https://github.com/modelstudioai/openwork/issues/66) | [#67](https://github.com/modelstudioai/openwork/pull/67) | — | 2026-07-08 | Opened by a prior run (not this ledger's author); reconciled from GitHub. Awaiting review. |
| chat-text-size | "Chat text size" (Small/Default/Large) in Appearance | Claude / ChatGPT desktop message text size | frontend-only | pr-open | [#64](https://github.com/modelstudioai/openwork/issues/64) | [#65](https://github.com/modelstudioai/openwork/pull/65) | — | 2026-07-08 | Prior run; reconciled from GitHub. Awaiting review. |
| conversation-width | "Conversation width" (Comfortable/Wide/Full) in Appearance | Claude / ChatGPT desktop content width | frontend-only | pr-open | [#62](https://github.com/modelstudioai/openwork/issues/62) | [#63](https://github.com/modelstudioai/openwork/pull/63) | — | 2026-07-08 | Prior run; reconciled from GitHub. Awaiting review. |
| composer-word-count | Live word / character count indicator in the composer | Codex / ChatGPT desktop composer counter | frontend-only | pr-open | [#60](https://github.com/modelstudioai/openwork/issues/60) | [#61](https://github.com/modelstudioai/openwork/pull/61) | — | 2026-07-08 | Prior run; reconciled from GitHub. Awaiting review. |
| shortcuts-search | Search box on Settings → Keyboard Shortcuts page | Claude / VS Code shortcuts search | frontend-only | pr-open | [#58](https://github.com/modelstudioai/openwork/issues/58) | [#59](https://github.com/modelstudioai/openwork/pull/59) | — | 2026-07-08 | Prior run; reconciled from GitHub. Awaiting review. |
| palette-recent | Surface recently-used commands in Command Palette (⌘K) | VS Code / Linear recent commands | frontend-only | pr-open | [#56](https://github.com/modelstudioai/openwork/issues/56) | [#57](https://github.com/modelstudioai/openwork/pull/57) | — | 2026-07-08 | Prior run; reconciled from GitHub. Awaiting review. |
| thinking-menu-shortcut | Keyboard shortcut (⌘⇧E) to open composer thinking menu | Claude Code Desktop ⌘⇧E | frontend-only | pr-open | [#54](https://github.com/modelstudioai/openwork/issues/54) | [#55](https://github.com/modelstudioai/openwork/pull/55) | loop/thinking-menu-shortcut | 2026-07-08 | Prior run; reconciled from GitHub. Awaiting review. |
| recall-prompts | Recall previously-sent prompts with Up/Down in composer | Claude Code / ChatGPT / shell history recall | frontend-only | pr-open | [#52](https://github.com/modelstudioai/openwork/issues/52) | [#53](https://github.com/modelstudioai/openwork/pull/53) | — | 2026-07-08 | Prior run; reconciled from GitHub. Awaiting review. |
| reduce-motion | "Reduce motion" accessibility setting in Appearance | Claude desktop / macOS / Windows reduce-motion + `prefers-reduced-motion` | frontend-only | merged | [#50](https://github.com/modelstudioai/openwork/issues/50) | [#51](https://github.com/modelstudioai/openwork/pull/51) | loop/reduce-motion | 2026-07-06 | **Merged** into `main` (2026-07-06). Renderer-only pref (localStorage) applied app-wide via `<MotionConfig reducedMotion>` + `data-reduce-motion` on `<html>` + global CSS guard. New `ReduceMotionProvider` in `main.tsx`; toggle in Appearance→Interface; 2 new i18n keys ×7 locales. Provider pattern reused by interface-zoom (#69). |
| 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). |
Expand Down
Loading