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 @@ -1697,8 +1697,11 @@ export const ChatDisplay = React.forwardRef<ChatDisplayHandle, ChatDisplayProps>
}}
>
<ScrollArea className="h-full min-w-0" viewportRef={scrollViewportRef}>
<div className={cn(
CHAT_LAYOUT.maxWidth,
<div
data-testid="chat-messages-container"
style={compactMode ? undefined : { maxWidth: 'var(--chat-content-max-width, 840px)' }}
className={cn(
compactMode && CHAT_LAYOUT.maxWidth,
"mx-auto min-w-0",
compactMode ? "px-3 py-4 space-y-2" : [CHAT_LAYOUT.containerPadding, CHAT_LAYOUT.messageSpacing]
)}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,9 @@ export function ChatInputZone({

return (
<div
style={compactMode ? undefined : { maxWidth: 'var(--chat-content-max-width, 840px)' }}
className={cn(
CHAT_LAYOUT.maxWidth,
compactMode && CHAT_LAYOUT.maxWidth,
'mx-auto w-full mt-1',
compactMode ? 'px-2 pb-3' : 'px-3 @xs/panel:px-4 pb-4',
className,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ 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 */
testId?: string
}

/**
Expand All @@ -50,10 +52,12 @@ export function SettingsSegmentedControl<T extends string = string>({
options,
size = 'md',
className,
testId,
}: SettingsSegmentedControlProps<T>) {
return (
<div
role="radiogroup"
data-testid={testId}
className={cn('inline-flex gap-1', className)}
>
{options.map((option) => {
Expand All @@ -65,6 +69,8 @@ export function SettingsSegmentedControl<T extends string = string>({
type="button"
role="radio"
aria-checked={isSelected}
data-testid={testId ? `${testId}-${option.value}` : undefined}
data-value={option.value}
onClick={() => onValueChange(option.value)}
className={cn(
'flex items-center gap-1.5 rounded-lg transition-all',
Expand Down
96 changes: 96 additions & 0 deletions apps/electron/src/renderer/context/ConversationWidthContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* ConversationWidthContext
*
* App-wide "Conversation width" preference — controls the reading-column width
* of the chat transcript and composer, mirroring the width controls in
* comparable desktop clients (Claude Desktop, ChatGPT desktop, Codex / VS Code).
*
* Three modes:
* - `comfortable` (default) — the classic ~840px reading column;
* - `wide` — a roomier 1100px column for code-heavy conversations;
* - `full` — no max-width, uses the full available panel width.
*
* When applied it:
* - sets `data-conversation-width="<mode>"` on `<html>` (for CSS hooks + tests);
* - drives a CSS custom property `--chat-content-max-width` on `<html>`
* (`840px` / `1100px` / `none`). The transcript container and composer read
* it via `max-width: var(--chat-content-max-width, 840px)`, so the fallback
* keeps the shared web viewer (`packages/ui`) unchanged at 840px.
*
* 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 * as storage from '@/lib/local-storage'

export type ConversationWidth = 'comfortable' | 'wide' | 'full'

/** Resolved CSS `max-width` value for each mode. */
const MAX_WIDTH_BY_MODE: Record<ConversationWidth, string> = {
comfortable: '840px',
wide: '1100px',
full: 'none',
}

const CONVERSATION_WIDTH_ATTR = 'data-conversation-width'
const MAX_WIDTH_VAR = '--chat-content-max-width'

const DEFAULT_WIDTH: ConversationWidth = 'comfortable'

function isConversationWidth(value: unknown): value is ConversationWidth {
return value === 'comfortable' || value === 'wide' || value === 'full'
}

interface ConversationWidthContextType {
conversationWidth: ConversationWidth
setConversationWidth: (value: ConversationWidth) => void
}

const ConversationWidthContext = createContext<ConversationWidthContextType | null>(null)

/** Reflect the preference onto <html> so CSS + the transcript/composer can react. */
function applyConversationWidth(mode: ConversationWidth): void {
const root = document.documentElement
root.setAttribute(CONVERSATION_WIDTH_ATTR, mode)
root.style.setProperty(MAX_WIDTH_VAR, MAX_WIDTH_BY_MODE[mode])
}

export function ConversationWidthProvider({ children }: { children: ReactNode }) {
const [conversationWidth, setConversationWidthState] = useState<ConversationWidth>(() => {
const stored = storage.get<ConversationWidth>(storage.KEYS.conversationWidth, DEFAULT_WIDTH)
return isConversationWidth(stored) ? stored : DEFAULT_WIDTH
})

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

const setConversationWidth = useCallback((value: ConversationWidth) => {
setConversationWidthState(value)
storage.set(storage.KEYS.conversationWidth, value)
applyConversationWidth(value)
}, [])

return (
<ConversationWidthContext.Provider value={{ conversationWidth, setConversationWidth }}>
{children}
</ConversationWidthContext.Provider>
)
}

export function useConversationWidth(): ConversationWidthContextType {
const ctx = useContext(ConversationWidthContext)
if (!ctx) {
throw new Error('useConversationWidth must be used within a ConversationWidthProvider')
}
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
conversationWidth: 'conversation-width', // Chat reading-column width: comfortable | wide | full

// 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 { ConversationWidthProvider } from './context/ConversationWidthContext'
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 />
<ConversationWidthProvider>
<App />
<Toaster />
<PetWindowController />
</ConversationWidthProvider>
</ReduceMotionProvider>
</ThemeProvider>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ 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 { useConversationWidth } from '@/context/ConversationWidthContext'
import { useAppShellContext } from '@/context/AppShellContext'
import { routes } from '@/lib/navigate'
import { FolderOpen, Monitor, RefreshCw, Sun, Moon } from 'lucide-react'
Expand Down Expand Up @@ -143,6 +144,9 @@ export default function AppearanceSettingsPage() {
// Reduce motion toggle (renderer-only preference, persisted in localStorage)
const { reduceMotion, setReduceMotion } = useReduceMotion()

// Conversation width (renderer-only preference, persisted in localStorage)
const { conversationWidth, setConversationWidth } = useConversationWidth()

// Pet companion settings + custom pets (synced via shared Jotai atoms)
const {
pets,
Expand Down Expand Up @@ -387,6 +391,21 @@ export default function AppearanceSettingsPage() {
onCheckedChange={setReduceMotion}
testId="reduce-motion-toggle"
/>
<SettingsRow
label={t("settings.appearance.conversationWidth")}
description={t("settings.appearance.conversationWidthDesc")}
>
<SettingsSegmentedControl
value={conversationWidth}
onValueChange={setConversationWidth}
testId="conversation-width-control"
options={[
{ value: 'comfortable', label: t("settings.appearance.conversationWidthComfortable") },
{ value: 'wide', label: t("settings.appearance.conversationWidthWide") },
{ value: 'full', label: t("settings.appearance.conversationWidthFull") },
]}
/>
</SettingsRow>
</SettingsCard>
</SettingsSection>

Expand Down
8 changes: 7 additions & 1 deletion docs/loop/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ 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). |
| 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-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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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