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 @@ -1712,8 +1712,11 @@ export const ChatDisplay = React.forwardRef<ChatDisplayHandle, ChatDisplayProps>
}}
>
<ScrollArea className="h-full min-w-0" viewportRef={scrollViewportRef} data-testid="chat-transcript">
<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 @@ -62,6 +62,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
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 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 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). |
| 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. |
Expand Down
123 changes: 123 additions & 0 deletions e2e/assertions/conversation-width.assert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Feature assertion: the "Conversation width" segmented control in
* Settings → Appearance actually applies and persists an app-wide reading-column
* width preference.
*
* Drives the real UI over CDP entirely in the draft/no-session state (no seeded
* conversation, no backend connection): opens Settings → Appearance, selects each
* width option, and asserts the observable effects that the transcript + composer
* consume — the selected radio state, the `data-conversation-width` attribute on
* <html>, the computed `--chat-content-max-width` CSS custom property (the value
* the chat containers read via `max-width: var(--chat-content-max-width, 840px)`),
* and the persisted localStorage value.
*
* Cycling through all three options proves it both applies and reverts, not merely
* renders.
*/

import type { Assertion } from '../runner';

const SETTINGS_NAV = '[data-testid="nav:settings"]';
const APPEARANCE_NAV = '[data-testid="settings-nav-appearance"]';
const CONTROL = '[data-testid="conversation-width-control"]';
const STORAGE_KEY = 'craft-conversation-width';

type Mode = 'comfortable' | 'wide' | 'full';

const EXPECTED_MAX_WIDTH: Record<Mode, string> = {
comfortable: '840px',
wide: '1100px',
full: 'none',
};

/** aria-checked ("true" | "false" | null) for a given option button. */
function optionCheckedExpr(mode: Mode): string {
return `(() => {
const el = document.querySelector('[data-testid="conversation-width-control-${mode}"]');
return el ? el.getAttribute('aria-checked') : null;
})()`;
}

/** The `data-conversation-width` marker value on <html>. */
function htmlModeExpr(): string {
return `document.documentElement.getAttribute('data-conversation-width')`;
}

/** The computed `--chat-content-max-width` custom property on <html>. */
function cssVarExpr(): string {
return `getComputedStyle(document.documentElement).getPropertyValue('--chat-content-max-width').trim()`;
}

/** The persisted localStorage value (JSON-encoded string) for the preference. */
function storedValueExpr(): string {
return `window.localStorage.getItem(${JSON.stringify(STORAGE_KEY)})`;
}

const assertion: Assertion = {
name: 'conversation-width control applies and persists an app-wide width preference',
async run(app) {
const { session } = app;

// App fully mounted.
await session.waitForFunction(
'!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0',
{ timeoutMs: 30000, message: 'app did not mount' },
);

// Open Settings → Appearance (real user path).
await session.click(SETTINGS_NAV, { timeoutMs: 15000 });
await session.click(APPEARANCE_NAV, { timeoutMs: 15000 });

// The control is the feature under test — its presence is the first signal.
await session.waitForSelector(CONTROL, {
timeoutMs: 15000,
message: 'conversation-width control did not render',
});

// Initial state: comfortable selected, <html> marked comfortable, 840px column.
const initialChecked = await session.evaluate<string | null>(optionCheckedExpr('comfortable'));
if (initialChecked !== 'true') {
throw new Error(`expected "comfortable" selected initially, saw aria-checked=${initialChecked}`);
}
const initialMode = await session.evaluate<string | null>(htmlModeExpr());
if (initialMode !== 'comfortable') {
throw new Error(`expected data-conversation-width="comfortable" initially, saw ${JSON.stringify(initialMode)}`);
}
const initialVar = await session.evaluate<string>(cssVarExpr());
if (initialVar !== EXPECTED_MAX_WIDTH.comfortable) {
throw new Error(`expected --chat-content-max-width=840px initially, saw ${JSON.stringify(initialVar)}`);
}

// Selecting each mode applies + persists; the final "comfortable" proves revert.
const sequence: Mode[] = ['full', 'wide', 'comfortable'];
for (const mode of sequence) {
await session.click(`[data-testid="conversation-width-control-${mode}"]`);

// Radio state flips on.
await session.waitForFunction(
`${optionCheckedExpr(mode)} === 'true'`,
{ timeoutMs: 5000, message: `"${mode}" option did not become selected` },
);

// <html> marker updates.
await session.waitForFunction(
`${htmlModeExpr()} === ${JSON.stringify(mode)}`,
{ timeoutMs: 5000, message: `data-conversation-width did not update to "${mode}"` },
);

// CSS custom property (what the chat containers actually read) updates.
await session.waitForFunction(
`${cssVarExpr()} === ${JSON.stringify(EXPECTED_MAX_WIDTH[mode])}`,
{ timeoutMs: 5000, message: `--chat-content-max-width did not become ${EXPECTED_MAX_WIDTH[mode]} for "${mode}"` },
);

// Persisted (JSON-encoded string).
const stored = await session.evaluate<string | null>(storedValueExpr());
if (stored !== JSON.stringify(mode)) {
throw new Error(`expected persisted ${JSON.stringify(JSON.stringify(mode))} after selecting "${mode}", saw ${JSON.stringify(stored)}`);
}
}
},
};

export default assertion;
5 changes: 5 additions & 0 deletions packages/shared/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,11 @@
"settings.appearance.noToolIcons": "Keine Werkzeugsymbol-Zuordnungen gefunden",
"settings.appearance.reduceMotion": "Bewegung reduzieren",
"settings.appearance.reduceMotionDesc": "Animationen und Übergänge in der gesamten App minimieren.",
"settings.appearance.conversationWidth": "Unterhaltungsbreite",
"settings.appearance.conversationWidthDesc": "Legt fest, wie breit der Chatverlauf und das Eingabefeld sind.",
"settings.appearance.conversationWidthComfortable": "Komfortabel",
"settings.appearance.conversationWidthWide": "Breit",
"settings.appearance.conversationWidthFull": "Voll",
"settings.appearance.pet": "Begleiter",
"settings.appearance.petCustom": "Eigene Begleiter",
"settings.appearance.petCustomHint": "Füge einen Ordner mit pet.json und einem 8x9-Spritesheet hinzu und aktualisiere dann.",
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,11 @@
"settings.appearance.noToolIcons": "No tool icon mappings found",
"settings.appearance.reduceMotion": "Reduce motion",
"settings.appearance.reduceMotionDesc": "Minimize animations and transitions throughout the app.",
"settings.appearance.conversationWidth": "Conversation width",
"settings.appearance.conversationWidthDesc": "Set how wide the chat transcript and composer are.",
"settings.appearance.conversationWidthComfortable": "Comfortable",
"settings.appearance.conversationWidthWide": "Wide",
"settings.appearance.conversationWidthFull": "Full",
"settings.appearance.pet": "Pet",
"settings.appearance.petCustom": "Custom pets",
"settings.appearance.petCustomHint": "Add a pet folder with pet.json and an 8x9 spritesheet, then refresh.",
Expand Down
Loading