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
Original file line number Diff line number Diff line change
Expand Up @@ -1717,7 +1717,7 @@ export const ChatDisplay = React.forwardRef<ChatDisplayHandle, ChatDisplayProps>
style={compactMode ? undefined : { maxWidth: 'var(--chat-content-max-width, 840px)' }}
className={cn(
compactMode && CHAT_LAYOUT.maxWidth,
"mx-auto min-w-0",
"chat-text-scope mx-auto min-w-0",
compactMode ? "px-3 py-4 space-y-2" : [CHAT_LAYOUT.containerPadding, CHAT_LAYOUT.messageSpacing]
)}>
{/* Session-level AnimatePresence: Prevents layout jump when switching sessions */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ export interface SettingsSegmentedControlProps<T extends string = string> {
size?: 'sm' | 'md'
/** Additional className */
className?: string
/** Optional test id — applied to the group and, suffixed with `-<value>`, to each option */
/**
* Optional test id. When set, `data-testid` is applied to the group and,
* suffixed with `-<value>`, to each option. Every option button also
* carries a `data-value` attribute, so tests can address a specific
* segment without depending on its (localized) label.
*/
testId?: string
}

Expand Down
90 changes: 90 additions & 0 deletions apps/electron/src/renderer/context/ChatTextSizeContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* ChatTextSizeContext
*
* App-wide "Chat text size" preference — scales the typography of the
* conversation transcript without resizing the rest of the app.
*
* Unlike the OS-level window zoom wired to the View menu
* (`webContents.setZoomFactor`), which scales the entire UI (sidebar, toolbar,
* composer, icons, spacing), this preference only affects the reading text in
* chat. It works by reflecting the choice onto `<html>` as
* `data-chat-text-size="small|medium|large"`; the global CSS in `index.css`
* maps that to a `--chat-font-scale` custom property, which the chat transcript
* container (`.chat-text-scope` in `ChatDisplay`) consumes via
* `font-size: calc(1em * var(--chat-font-scale))`. Because the scale is
* `em`-relative it is exactly neutral at `medium` (1×) and scales the inherited
* message text up/down at `small` (0.9×) / `large` (1.15×).
*
* The preference is persisted in `localStorage` (renderer-only, no backend),
* mirroring the other lightweight UI prefs in `lib/local-storage.ts` and the
* sibling `ReduceMotionContext`.
*/

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

export type ChatTextSize = 'small' | 'medium' | 'large'

const CHAT_TEXT_SIZES: readonly ChatTextSize[] = ['small', 'medium', 'large']

const DEFAULT_CHAT_TEXT_SIZE: ChatTextSize = 'medium'

interface ChatTextSizeContextType {
chatTextSize: ChatTextSize
setChatTextSize: (value: ChatTextSize) => void
}

const ChatTextSizeContext = createContext<ChatTextSizeContextType | null>(null)

const CHAT_TEXT_SIZE_ATTR = 'data-chat-text-size'

/** Guard against malformed persisted values. */
function normalize(value: unknown): ChatTextSize {
return CHAT_TEXT_SIZES.includes(value as ChatTextSize)
? (value as ChatTextSize)
: DEFAULT_CHAT_TEXT_SIZE
}

/** Reflect the preference onto <html> so the global CSS var can react. */
function applyChatTextSizeAttribute(size: ChatTextSize): void {
document.documentElement.setAttribute(CHAT_TEXT_SIZE_ATTR, size)
}

export function ChatTextSizeProvider({ children }: { children: ReactNode }) {
const [chatTextSize, setChatTextSizeState] = useState<ChatTextSize>(() =>
normalize(storage.get<ChatTextSize>(storage.KEYS.chatTextSize, DEFAULT_CHAT_TEXT_SIZE)),
)

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

const setChatTextSize = useCallback((value: ChatTextSize) => {
const next = normalize(value)
setChatTextSizeState(next)
storage.set(storage.KEYS.chatTextSize, next)
applyChatTextSizeAttribute(next)
}, [])

return (
<ChatTextSizeContext.Provider value={{ chatTextSize, setChatTextSize }}>
{children}
</ChatTextSizeContext.Provider>
)
}

export function useChatTextSize(): ChatTextSizeContextType {
const ctx = useContext(ChatTextSizeContext)
if (!ctx) {
throw new Error('useChatTextSize must be used within a ChatTextSizeProvider')
}
return ctx
}
20 changes: 20 additions & 0 deletions apps/electron/src/renderer/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1386,3 +1386,23 @@ html.dark[data-scenic] .fullscreen-overlay-background {
transition-delay: 0ms !important;
scroll-behavior: auto !important;
}

/* Chat text size: the "Chat text size" control in Appearance settings reflects
its choice onto <html> as data-chat-text-size. That maps to the
--chat-font-scale custom property, which the chat transcript container
(.chat-text-scope) consumes as `font-size: calc(1em * var(--chat-font-scale))`.
Being em-relative keeps the default (medium) exactly neutral and scales only
the conversation's reading text — the sidebar, top bar, composer, and icons
keep their native size. */
:root {
--chat-font-scale: 1;
}
[data-chat-text-size='small'] {
--chat-font-scale: 0.9;
}
[data-chat-text-size='large'] {
--chat-font-scale: 1.15;
}
.chat-text-scope {
font-size: calc(1em * var(--chat-font-scale));
}
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 @@ -63,6 +63,7 @@ export const KEYS = {
showConnectionIcons: 'show-connection-icons',
reduceMotion: 'reduce-motion', // Minimize animations/transitions app-wide
conversationWidth: 'conversation-width', // Chat reading-column width: comfortable | wide | full
chatTextSize: 'chat-text-size', // Scale conversation text without resizing the app chrome

// 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 @@ -8,6 +8,7 @@ import App from './App'
import { ThemeProvider } from './context/ThemeContext'
import { ReduceMotionProvider } from './context/ReduceMotionContext'
import { ConversationWidthProvider } from './context/ConversationWidthContext'
import { ChatTextSizeProvider } from './context/ChatTextSizeContext'
import { windowWorkspaceIdAtom } from './atoms/sessions'
import { Toaster } from '@/components/ui/sonner'
import { PetWindowController } from '@/components/pet/PetWindowController'
Expand Down Expand Up @@ -110,9 +111,11 @@ function Root() {
<ThemeProvider activeWorkspaceId={workspaceId}>
<ReduceMotionProvider>
<ConversationWidthProvider>
<App />
<Toaster />
<PetWindowController />
<ChatTextSizeProvider>
<App />
<Toaster />
<PetWindowController />
</ChatTextSizeProvider>
</ConversationWidthProvider>
</ReduceMotionProvider>
</ThemeProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { EditPopover, EditButton, getEditConfig } from '@/components/ui/EditPopo
import { useTheme } from '@/context/ThemeContext'
import { useReduceMotion } from '@/context/ReduceMotionContext'
import { useConversationWidth } from '@/context/ConversationWidthContext'
import { useChatTextSize, type ChatTextSize } from '@/context/ChatTextSizeContext'
import { useAppShellContext } from '@/context/AppShellContext'
import { routes } from '@/lib/navigate'
import { FolderOpen, Monitor, RefreshCw, Sun, Moon } from 'lucide-react'
Expand Down Expand Up @@ -146,6 +147,8 @@ export default function AppearanceSettingsPage() {

// Conversation width (renderer-only preference, persisted in localStorage)
const { conversationWidth, setConversationWidth } = useConversationWidth()
// Chat text size (renderer-only preference, persisted in localStorage)
const { chatTextSize, setChatTextSize } = useChatTextSize()

// Pet companion settings + custom pets (synced via shared Jotai atoms)
const {
Expand Down Expand Up @@ -406,6 +409,21 @@ export default function AppearanceSettingsPage() {
]}
/>
</SettingsRow>
<SettingsRow
label={t("settings.appearance.chatTextSize")}
description={t("settings.appearance.chatTextSizeDesc")}
>
<SettingsSegmentedControl
value={chatTextSize}
onValueChange={(value) => setChatTextSize(value as ChatTextSize)}
testId="chat-text-size-control"
options={[
{ value: 'small', label: t("settings.appearance.chatTextSizeSmall") },
{ value: 'medium', label: t("settings.appearance.chatTextSizeDefault") },
{ value: 'large', label: t("settings.appearance.chatTextSizeLarge") },
]}
/>
</SettingsRow>
</SettingsCard>
</SettingsSection>

Expand Down
1 change: 1 addition & 0 deletions docs/loop/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ log, not the system of record.

| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| chat-text-size | "Chat text size" setting (Small / Default / Large) in Appearance | Claude Desktop "Chat font" + anthropics/claude-code #50543/#48887; ChatGPT desktop font-size requests | frontend-only | pr-open | [#64](https://github.com/modelstudioai/openwork/issues/64) | [#65](https://github.com/modelstudioai/openwork/pull/65) | loop/chat-text-size | 2026-07-07 | Renderer-only pref (localStorage `craft-chat-text-size`) reflected onto `<html>` as `data-chat-text-size` + `--chat-font-scale` CSS var (0.9/1/1.15). Transcript container gets `.chat-text-scope` with `font-size: calc(1em * var(--chat-font-scale))` — em-relative ⇒ neutral at Default, scales only conversation text (chrome untouched). New `ChatTextSizeProvider`; segmented control in Appearance→Interface; `SettingsSegmentedControl` gained optional `testId`/`data-value`; 5 new i18n keys ×7 locales. typecheck/`bun test` **zero-delta vs main** (11 pre-existing tsc errors + 56 pre-existing fail-lines byte-identical); renderer build ✅; i18n parity ✅. CDP assertion authored (drives control, asserts attr/CSS-var/localStorage + probe computed font-size ratio ~1.15/0.9); **could not execute** — Electron runtime binary download is org-egress-policy 403 (same block as #51). |
| conversation-width | "Conversation width" (Comfortable / Wide / Full) setting in Appearance | Claude Desktop wider-chat / ChatGPT desktop width / Codex & VS Code content width | frontend-only | pr-open | [#62](https://github.com/modelstudioai/openwork/issues/62) | [#63](https://github.com/modelstudioai/openwork/pull/63) | loop/conversation-width | 2026-07-06 | Renderer-only pref (localStorage `craft-conversation-width`). New `ConversationWidthProvider` in `main.tsx` sets `data-conversation-width` on `<html>` + drives CSS var `--chat-content-max-width` (840px/1100px/none). Transcript container (`ChatDisplay`) + composer (`ChatInputZone`) read it via `max-width: var(--chat-content-max-width, 840px)`; fallback keeps shared web viewer unchanged. Segmented control in Appearance→Interface; added `testId` to `SettingsSegmentedControl`; 5 new i18n keys ×7 locales. typecheck/`bun test` zero-delta vs main (56-failure set byte-identical); renderer build ✅; i18n parity ✅. CDP assertion `e2e/assertions/conversation-width.assert.ts` included; **could not run locally** (egress 403 blocks Electron binary + `libsignal-node`/`eslint-config` git-tarball deps — same env blocker as #51). |
| composer-word-count | Live word/character/line count indicator in the chat composer | Codex/Claude desktop composer counters + editor status bars | frontend-only | pr-open | [#60](https://github.com/modelstudioai/openwork/issues/60) | [#61](https://github.com/modelstudioai/openwork/pull/61) | loop/composer-word-count | 2026-07-06 | Pure `computeComposerCounts` module (+unit tests) → `ComposerCountIndicator` in the composer toolbar, derived from the existing `input` string; hidden when empty / in compact mode. 3 new plural-neutral i18n keys (`chat.composer{Words,Characters,Lines}`) in all 7 locales. typecheck:all zero-delta (11 pre-existing baseline errors, none in touched files); unit tests 10/10; i18n parity OK; renderer build ✅; assertion transpiles. **CDP could not run locally**: the e2e build 403s on the Electron binary download and the `libsignal` WhatsApp-worker dep (same egress block as prior loop PRs); `composer-count.assert.ts` included for CI/reviewer. |
| shortcuts-search | Search box on the Settings → Keyboard Shortcuts page | Codex "keypress search" + VS Code / Claude keybindings search; mirrors OpenWork's own settings-navigator search (#40) | frontend-only | pr-open | [#58](https://github.com/modelstudioai/openwork/issues/58) | [#59](https://github.com/modelstudioai/openwork/pull/59) | loop/shortcuts-search | 2026-07-05 | Pure renderer view over the action registry (`actionsByCategory` + `getHotkeyDisplay`); filters rows by action label **and** rendered key tokens ("keypress search"), hides empty sections, shows empty state. Reuses `common.search`/`noResultsFound`/`clear` (**zero** new i18n keys). Added `data-testid="settings-item-<id>"` to settings-nav items for e2e navigation. typecheck/renderer-build/i18n-parity zero-delta vs main; touched-area tests pass. CDP assertion written (app launch egress-blocked locally, same as prior rounds). |
Expand Down
Loading