Skip to content

Commit 2bb5bc0

Browse files
DragonnZhangclaude
andauthored
Add a "Conversation width" setting (Comfortable / Wide / Full) to Appearance (#63)
* 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 * docs(loop): record conversation-width PR #63 in ledger --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c8c5110 commit 2bb5bc0

16 files changed

Lines changed: 294 additions & 6 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
@@ -1712,8 +1712,11 @@ export const ChatDisplay = React.forwardRef<ChatDisplayHandle, ChatDisplayProps>
17121712
}}
17131713
>
17141714
<ScrollArea className="h-full min-w-0" viewportRef={scrollViewportRef} data-testid="chat-transcript">
1715-
<div className={cn(
1716-
CHAT_LAYOUT.maxWidth,
1715+
<div
1716+
data-testid="chat-messages-container"
1717+
style={compactMode ? undefined : { maxWidth: 'var(--chat-content-max-width, 840px)' }}
1718+
className={cn(
1719+
compactMode && CHAT_LAYOUT.maxWidth,
17171720
"mx-auto min-w-0",
17181721
compactMode ? "px-3 py-4 space-y-2" : [CHAT_LAYOUT.containerPadding, CHAT_LAYOUT.messageSpacing]
17191722
)}>

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
@@ -62,6 +62,7 @@ export const KEYS = {
6262
// Appearance
6363
showConnectionIcons: 'show-connection-icons',
6464
reduceMotion: 'reduce-motion', // Minimize animations/transitions app-wide
65+
conversationWidth: 'conversation-width', // Chat reading-column width: comfortable | wide | full
6566

6667
// What's New
6768
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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ log, not the system of record.
3333

3434
| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
3535
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
36+
| 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). |
3637
| 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. |
3738
| 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). |
3839
| command-palette-recents | "Recently used" section in the Command Palette (⌘K) | VS Code ⌘⇧P / Raycast / Linear ⌘K / Claude Code Desktop command menu | frontend-only | pr-open | [#56](https://github.com/modelstudioai/openwork/issues/56) | [#57](https://github.com/modelstudioai/openwork/pull/57) | loop/command-palette-recents | 2026-07-05 | Palette (#42) had no memory of what you run. Adds a persisted recents list (`localStorage` `command-palette-recents`, dedupe/cap-6, newest-first) surfaced as a top "Recently used" group, hidden while searching. Pure `pushRecent` module + 6 unit tests (pass). One new i18n key `commands.recent` × 7 locales. typecheck/`bun test` zero-delta vs main (56 baseline fails on both, 0 new); renderer build ✅. **CDP could not run locally** (same env block as prior rounds: egress 403s Electron binary + `libsignal`/WhatsApp-worker dep) — assertion included for CI/reviewer. |
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* Feature assertion: the "Conversation width" segmented control in
3+
* Settings → Appearance actually applies and persists an app-wide reading-column
4+
* width preference.
5+
*
6+
* Drives the real UI over CDP entirely in the draft/no-session state (no seeded
7+
* conversation, no backend connection): opens Settings → Appearance, selects each
8+
* width option, and asserts the observable effects that the transcript + composer
9+
* consume — the selected radio state, the `data-conversation-width` attribute on
10+
* <html>, the computed `--chat-content-max-width` CSS custom property (the value
11+
* the chat containers read via `max-width: var(--chat-content-max-width, 840px)`),
12+
* and the persisted localStorage value.
13+
*
14+
* Cycling through all three options proves it both applies and reverts, not merely
15+
* renders.
16+
*/
17+
18+
import type { Assertion } from '../runner';
19+
20+
const SETTINGS_NAV = '[data-testid="nav:settings"]';
21+
const APPEARANCE_NAV = '[data-testid="settings-nav-appearance"]';
22+
const CONTROL = '[data-testid="conversation-width-control"]';
23+
const STORAGE_KEY = 'craft-conversation-width';
24+
25+
type Mode = 'comfortable' | 'wide' | 'full';
26+
27+
const EXPECTED_MAX_WIDTH: Record<Mode, string> = {
28+
comfortable: '840px',
29+
wide: '1100px',
30+
full: 'none',
31+
};
32+
33+
/** aria-checked ("true" | "false" | null) for a given option button. */
34+
function optionCheckedExpr(mode: Mode): string {
35+
return `(() => {
36+
const el = document.querySelector('[data-testid="conversation-width-control-${mode}"]');
37+
return el ? el.getAttribute('aria-checked') : null;
38+
})()`;
39+
}
40+
41+
/** The `data-conversation-width` marker value on <html>. */
42+
function htmlModeExpr(): string {
43+
return `document.documentElement.getAttribute('data-conversation-width')`;
44+
}
45+
46+
/** The computed `--chat-content-max-width` custom property on <html>. */
47+
function cssVarExpr(): string {
48+
return `getComputedStyle(document.documentElement).getPropertyValue('--chat-content-max-width').trim()`;
49+
}
50+
51+
/** The persisted localStorage value (JSON-encoded string) for the preference. */
52+
function storedValueExpr(): string {
53+
return `window.localStorage.getItem(${JSON.stringify(STORAGE_KEY)})`;
54+
}
55+
56+
const assertion: Assertion = {
57+
name: 'conversation-width control applies and persists an app-wide width preference',
58+
async run(app) {
59+
const { session } = app;
60+
61+
// App fully mounted.
62+
await session.waitForFunction(
63+
'!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0',
64+
{ timeoutMs: 30000, message: 'app did not mount' },
65+
);
66+
67+
// Open Settings → Appearance (real user path).
68+
await session.click(SETTINGS_NAV, { timeoutMs: 15000 });
69+
await session.click(APPEARANCE_NAV, { timeoutMs: 15000 });
70+
71+
// The control is the feature under test — its presence is the first signal.
72+
await session.waitForSelector(CONTROL, {
73+
timeoutMs: 15000,
74+
message: 'conversation-width control did not render',
75+
});
76+
77+
// Initial state: comfortable selected, <html> marked comfortable, 840px column.
78+
const initialChecked = await session.evaluate<string | null>(optionCheckedExpr('comfortable'));
79+
if (initialChecked !== 'true') {
80+
throw new Error(`expected "comfortable" selected initially, saw aria-checked=${initialChecked}`);
81+
}
82+
const initialMode = await session.evaluate<string | null>(htmlModeExpr());
83+
if (initialMode !== 'comfortable') {
84+
throw new Error(`expected data-conversation-width="comfortable" initially, saw ${JSON.stringify(initialMode)}`);
85+
}
86+
const initialVar = await session.evaluate<string>(cssVarExpr());
87+
if (initialVar !== EXPECTED_MAX_WIDTH.comfortable) {
88+
throw new Error(`expected --chat-content-max-width=840px initially, saw ${JSON.stringify(initialVar)}`);
89+
}
90+
91+
// Selecting each mode applies + persists; the final "comfortable" proves revert.
92+
const sequence: Mode[] = ['full', 'wide', 'comfortable'];
93+
for (const mode of sequence) {
94+
await session.click(`[data-testid="conversation-width-control-${mode}"]`);
95+
96+
// Radio state flips on.
97+
await session.waitForFunction(
98+
`${optionCheckedExpr(mode)} === 'true'`,
99+
{ timeoutMs: 5000, message: `"${mode}" option did not become selected` },
100+
);
101+
102+
// <html> marker updates.
103+
await session.waitForFunction(
104+
`${htmlModeExpr()} === ${JSON.stringify(mode)}`,
105+
{ timeoutMs: 5000, message: `data-conversation-width did not update to "${mode}"` },
106+
);
107+
108+
// CSS custom property (what the chat containers actually read) updates.
109+
await session.waitForFunction(
110+
`${cssVarExpr()} === ${JSON.stringify(EXPECTED_MAX_WIDTH[mode])}`,
111+
{ timeoutMs: 5000, message: `--chat-content-max-width did not become ${EXPECTED_MAX_WIDTH[mode]} for "${mode}"` },
112+
);
113+
114+
// Persisted (JSON-encoded string).
115+
const stored = await session.evaluate<string | null>(storedValueExpr());
116+
if (stored !== JSON.stringify(mode)) {
117+
throw new Error(`expected persisted ${JSON.stringify(JSON.stringify(mode))} after selecting "${mode}", saw ${JSON.stringify(stored)}`);
118+
}
119+
}
120+
},
121+
};
122+
123+
export default assertion;

packages/shared/src/i18n/locales/de.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,11 @@
786786
"settings.appearance.noToolIcons": "Keine Werkzeugsymbol-Zuordnungen gefunden",
787787
"settings.appearance.reduceMotion": "Bewegung reduzieren",
788788
"settings.appearance.reduceMotionDesc": "Animationen und Übergänge in der gesamten App minimieren.",
789+
"settings.appearance.conversationWidth": "Unterhaltungsbreite",
790+
"settings.appearance.conversationWidthDesc": "Legt fest, wie breit der Chatverlauf und das Eingabefeld sind.",
791+
"settings.appearance.conversationWidthComfortable": "Komfortabel",
792+
"settings.appearance.conversationWidthWide": "Breit",
793+
"settings.appearance.conversationWidthFull": "Voll",
789794
"settings.appearance.pet": "Begleiter",
790795
"settings.appearance.petCustom": "Eigene Begleiter",
791796
"settings.appearance.petCustomHint": "Füge einen Ordner mit pet.json und einem 8x9-Spritesheet hinzu und aktualisiere dann.",

0 commit comments

Comments
 (0)