Skip to content

Commit c616c33

Browse files
committed
Add a "Conversation width" setting (Comfortable / Wide / Full) to Appearance
Adds a renderer-only "Conversation width" preference to Settings → Appearance → Interface, letting users widen the chat transcript and composer beyond the fixed 840px reading column — matching the width controls in Claude Desktop, ChatGPT desktop and Codex / VS Code. - New ConversationWidthProvider (localStorage `craft-conversation-width`, mirroring ReduceMotionContext) reflects the choice onto `<html>` as `data-conversation-width` and drives a `--chat-content-max-width` CSS variable (840px / 1100px / none). - ChatDisplay transcript container and ChatInputZone read the variable via `max-width: var(--chat-content-max-width, 840px)`; the fallback leaves the shared web viewer unchanged and compact/popover embeddings untouched. - Segmented control in the Appearance Interface section; adds an optional `testId` to SettingsSegmentedControl for e2e targeting. - 5 new i18n keys across all 7 locales. - CDP assertion drives the real Settings UI, cycling all three options and asserting the radio state, `<html>` attribute, computed CSS variable and persisted localStorage value. Closes #62
1 parent 8c468e4 commit c616c33

16 files changed

Lines changed: 300 additions & 7 deletions

File tree

apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1697,8 +1697,11 @@ export const ChatDisplay = React.forwardRef<ChatDisplayHandle, ChatDisplayProps>
16971697
}}
16981698
>
16991699
<ScrollArea className="h-full min-w-0" viewportRef={scrollViewportRef}>
1700-
<div className={cn(
1701-
CHAT_LAYOUT.maxWidth,
1700+
<div
1701+
data-testid="chat-messages-container"
1702+
style={compactMode ? undefined : { maxWidth: 'var(--chat-content-max-width, 840px)' }}
1703+
className={cn(
1704+
compactMode && CHAT_LAYOUT.maxWidth,
17021705
"mx-auto min-w-0",
17031706
compactMode ? "px-3 py-4 space-y-2" : [CHAT_LAYOUT.containerPadding, CHAT_LAYOUT.messageSpacing]
17041707
)}>

apps/electron/src/renderer/components/app-shell/input/ChatInputZone.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,9 @@ export function ChatInputZone({
8383

8484
return (
8585
<div
86+
style={compactMode ? undefined : { maxWidth: 'var(--chat-content-max-width, 840px)' }}
8687
className={cn(
87-
CHAT_LAYOUT.maxWidth,
88+
compactMode && CHAT_LAYOUT.maxWidth,
8889
'mx-auto w-full mt-1',
8990
compactMode ? 'px-2 pb-3' : 'px-3 @xs/panel:px-4 pb-4',
9091
className,

apps/electron/src/renderer/components/settings/SettingsSegmentedControl.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export interface SettingsSegmentedControlProps<T extends string = string> {
2828
size?: 'sm' | 'md'
2929
/** Additional className */
3030
className?: string
31+
/** Optional test id — applied to the group and, suffixed with `-<value>`, to each option */
32+
testId?: string
3133
}
3234

3335
/**
@@ -50,10 +52,12 @@ export function SettingsSegmentedControl<T extends string = string>({
5052
options,
5153
size = 'md',
5254
className,
55+
testId,
5356
}: SettingsSegmentedControlProps<T>) {
5457
return (
5558
<div
5659
role="radiogroup"
60+
data-testid={testId}
5761
className={cn('inline-flex gap-1', className)}
5862
>
5963
{options.map((option) => {
@@ -65,6 +69,8 @@ export function SettingsSegmentedControl<T extends string = string>({
6569
type="button"
6670
role="radio"
6771
aria-checked={isSelected}
72+
data-testid={testId ? `${testId}-${option.value}` : undefined}
73+
data-value={option.value}
6874
onClick={() => onValueChange(option.value)}
6975
className={cn(
7076
'flex items-center gap-1.5 rounded-lg transition-all',
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* ConversationWidthContext
3+
*
4+
* App-wide "Conversation width" preference — controls the reading-column width
5+
* of the chat transcript and composer, mirroring the width controls in
6+
* comparable desktop clients (Claude Desktop, ChatGPT desktop, Codex / VS Code).
7+
*
8+
* Three modes:
9+
* - `comfortable` (default) — the classic ~840px reading column;
10+
* - `wide` — a roomier 1100px column for code-heavy conversations;
11+
* - `full` — no max-width, uses the full available panel width.
12+
*
13+
* When applied it:
14+
* - sets `data-conversation-width="<mode>"` on `<html>` (for CSS hooks + tests);
15+
* - drives a CSS custom property `--chat-content-max-width` on `<html>`
16+
* (`840px` / `1100px` / `none`). The transcript container and composer read
17+
* it via `max-width: var(--chat-content-max-width, 840px)`, so the fallback
18+
* keeps the shared web viewer (`packages/ui`) unchanged at 840px.
19+
*
20+
* The preference is persisted in `localStorage` (renderer-only, no backend),
21+
* mirroring the other lightweight UI prefs in `lib/local-storage.ts`.
22+
*/
23+
24+
import React, {
25+
createContext,
26+
useContext,
27+
useState,
28+
useEffect,
29+
useCallback,
30+
type ReactNode,
31+
} from 'react'
32+
import * as storage from '@/lib/local-storage'
33+
34+
export type ConversationWidth = 'comfortable' | 'wide' | 'full'
35+
36+
/** Resolved CSS `max-width` value for each mode. */
37+
const MAX_WIDTH_BY_MODE: Record<ConversationWidth, string> = {
38+
comfortable: '840px',
39+
wide: '1100px',
40+
full: 'none',
41+
}
42+
43+
const CONVERSATION_WIDTH_ATTR = 'data-conversation-width'
44+
const MAX_WIDTH_VAR = '--chat-content-max-width'
45+
46+
const DEFAULT_WIDTH: ConversationWidth = 'comfortable'
47+
48+
function isConversationWidth(value: unknown): value is ConversationWidth {
49+
return value === 'comfortable' || value === 'wide' || value === 'full'
50+
}
51+
52+
interface ConversationWidthContextType {
53+
conversationWidth: ConversationWidth
54+
setConversationWidth: (value: ConversationWidth) => void
55+
}
56+
57+
const ConversationWidthContext = createContext<ConversationWidthContextType | null>(null)
58+
59+
/** Reflect the preference onto <html> so CSS + the transcript/composer can react. */
60+
function applyConversationWidth(mode: ConversationWidth): void {
61+
const root = document.documentElement
62+
root.setAttribute(CONVERSATION_WIDTH_ATTR, mode)
63+
root.style.setProperty(MAX_WIDTH_VAR, MAX_WIDTH_BY_MODE[mode])
64+
}
65+
66+
export function ConversationWidthProvider({ children }: { children: ReactNode }) {
67+
const [conversationWidth, setConversationWidthState] = useState<ConversationWidth>(() => {
68+
const stored = storage.get<ConversationWidth>(storage.KEYS.conversationWidth, DEFAULT_WIDTH)
69+
return isConversationWidth(stored) ? stored : DEFAULT_WIDTH
70+
})
71+
72+
// Keep the DOM in sync (also covers the initial value on mount).
73+
useEffect(() => {
74+
applyConversationWidth(conversationWidth)
75+
}, [conversationWidth])
76+
77+
const setConversationWidth = useCallback((value: ConversationWidth) => {
78+
setConversationWidthState(value)
79+
storage.set(storage.KEYS.conversationWidth, value)
80+
applyConversationWidth(value)
81+
}, [])
82+
83+
return (
84+
<ConversationWidthContext.Provider value={{ conversationWidth, setConversationWidth }}>
85+
{children}
86+
</ConversationWidthContext.Provider>
87+
)
88+
}
89+
90+
export function useConversationWidth(): ConversationWidthContextType {
91+
const ctx = useContext(ConversationWidthContext)
92+
if (!ctx) {
93+
throw new Error('useConversationWidth must be used within a ConversationWidthProvider')
94+
}
95+
return ctx
96+
}

apps/electron/src/renderer/lib/local-storage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export const KEYS = {
5353
// Appearance
5454
showConnectionIcons: 'show-connection-icons',
5555
reduceMotion: 'reduce-motion', // Minimize animations/transitions app-wide
56+
conversationWidth: 'conversation-width', // Chat reading-column width: comfortable | wide | full
5657

5758
// What's New
5859
whatsNewLastSeenVersion: 'whats-new-last-seen-version',

apps/electron/src/renderer/main.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Provider as JotaiProvider, useAtomValue } from 'jotai'
77
import App from './App'
88
import { ThemeProvider } from './context/ThemeContext'
99
import { ReduceMotionProvider } from './context/ReduceMotionContext'
10+
import { ConversationWidthProvider } from './context/ConversationWidthContext'
1011
import { windowWorkspaceIdAtom } from './atoms/sessions'
1112
import { Toaster } from '@/components/ui/sonner'
1213
import { PetWindowController } from '@/components/pet/PetWindowController'
@@ -108,9 +109,11 @@ function Root() {
108109
return (
109110
<ThemeProvider activeWorkspaceId={workspaceId}>
110111
<ReduceMotionProvider>
111-
<App />
112-
<Toaster />
113-
<PetWindowController />
112+
<ConversationWidthProvider>
113+
<App />
114+
<Toaster />
115+
<PetWindowController />
116+
</ConversationWidthProvider>
114117
</ReduceMotionProvider>
115118
</ThemeProvider>
116119
)

apps/electron/src/renderer/pages/settings/AppearanceSettingsPage.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { HeaderMenu } from '@/components/ui/HeaderMenu'
1515
import { EditPopover, EditButton, getEditConfig } from '@/components/ui/EditPopover'
1616
import { useTheme } from '@/context/ThemeContext'
1717
import { useReduceMotion } from '@/context/ReduceMotionContext'
18+
import { useConversationWidth } from '@/context/ConversationWidthContext'
1819
import { useAppShellContext } from '@/context/AppShellContext'
1920
import { routes } from '@/lib/navigate'
2021
import { FolderOpen, Monitor, RefreshCw, Sun, Moon } from 'lucide-react'
@@ -143,6 +144,9 @@ export default function AppearanceSettingsPage() {
143144
// Reduce motion toggle (renderer-only preference, persisted in localStorage)
144145
const { reduceMotion, setReduceMotion } = useReduceMotion()
145146

147+
// Conversation width (renderer-only preference, persisted in localStorage)
148+
const { conversationWidth, setConversationWidth } = useConversationWidth()
149+
146150
// Pet companion settings + custom pets (synced via shared Jotai atoms)
147151
const {
148152
pets,
@@ -387,6 +391,21 @@ export default function AppearanceSettingsPage() {
387391
onCheckedChange={setReduceMotion}
388392
testId="reduce-motion-toggle"
389393
/>
394+
<SettingsRow
395+
label={t("settings.appearance.conversationWidth")}
396+
description={t("settings.appearance.conversationWidthDesc")}
397+
>
398+
<SettingsSegmentedControl
399+
value={conversationWidth}
400+
onValueChange={setConversationWidth}
401+
testId="conversation-width-control"
402+
options={[
403+
{ value: 'comfortable', label: t("settings.appearance.conversationWidthComfortable") },
404+
{ value: 'wide', label: t("settings.appearance.conversationWidthWide") },
405+
{ value: 'full', label: t("settings.appearance.conversationWidthFull") },
406+
]}
407+
/>
408+
</SettingsRow>
390409
</SettingsCard>
391410
</SettingsSection>
392411

docs/loop/feature-ledger.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,13 @@ log, not the system of record.
3232

3333
| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
3434
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
35-
| 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). |
35+
| 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) | [#PR](https://github.com/modelstudioai/openwork/pull/PR) | 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). |
36+
| composer-count | Live word / character count indicator in the chat composer | Codex desktop / VS Code / Google Docs status bars | frontend-only | pr-open | [#60](https://github.com/modelstudioai/openwork/issues/60) | [#61](https://github.com/modelstudioai/openwork/pull/61) | loop/composer-count | 2026-07-05 | Opened by a prior run. Draft word count + tooltip (words/chars/lines) in composer toolbar. Awaiting review. |
37+
| shortcuts-search | Search box on the Settings → Keyboard Shortcuts page | Claude Code Desktop / VS Code keybinding search | 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-04 | Opened by a prior run. Awaiting review. |
38+
| recent-commands | Surface recently-used commands in the Command Palette (⌘K) | Claude Code Desktop / VS Code recently-used | frontend-only | pr-open | [#56](https://github.com/modelstudioai/openwork/issues/56) | [#57](https://github.com/modelstudioai/openwork/pull/57) | loop/recent-commands | 2026-07-04 | Opened by a prior run. Awaiting review. |
39+
| thinking-shortcut | Keyboard shortcut (⌘⇧E) to open the composer's thinking menu | Claude Code Desktop effort menu ⌘⇧E | frontend-only | pr-open | [#54](https://github.com/modelstudioai/openwork/issues/54) | [#55](https://github.com/modelstudioai/openwork/pull/55) | loop/thinking-shortcut | 2026-07-03 | Opened by a prior run. Awaiting review. |
40+
| prompt-history | Recall previously-sent prompts in the composer with Up / Down arrows | Claude Code / shell / ChatGPT prompt history | frontend-only | pr-open | [#52](https://github.com/modelstudioai/openwork/issues/52) | [#53](https://github.com/modelstudioai/openwork/pull/53) | loop/prompt-history | 2026-07-03 | Opened by a prior run. Awaiting review. |
41+
| 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`. 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. |
3642
| 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. |
3743
| 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. |
3844
| 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). |

0 commit comments

Comments
 (0)