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 @@ -1699,8 +1699,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 @@ -56,6 +56,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
7 changes: 6 additions & 1 deletion docs/loop/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ log, not the system of record.

| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| copy-message | "Copy" (copy-as-Markdown) action on assistant/agent responses | Codex desktop "Copy as Markdown" (openai/codex #2880, #17241) + Claude/ChatGPT desktop per-message copy | frontend-only | pr-open | [#70](https://github.com/modelstudioai/openwork/issues/70) | [#71](https://github.com/modelstudioai/openwork/pull/71) | loop/copy-message | 2026-07-08 | User messages + code blocks were already copyable, but full assistant responses had only a pop-out button — no copy. Extracted `AssistantMessage` component in `ChatDisplay.tsx` (owns `copied` state, mirrors the existing `ErrorMessage` extraction) with a hover copy button next to pop-out; copies raw `message.content` via `navigator.clipboard.writeText`, 2s "copied" check state, `toast.copyFailed` on error. **Zero new i18n keys** (reuses `common.copy`/`common.copied`/`toast.copyFailed`). Added a general `seed` hook to the e2e harness (`app.ts`/`runner.ts`) so assertions can pre-seed an on-disk session the embedded `SessionManager` loads on boot (no backend). typecheck:all zero-delta; `bun test` 56-failure set byte-identical to main; renderer build ✅; i18n parity ✅ (1544 keys); eslint 0 errors. CDP assertion (`copy-message.assert.ts`) seeds a user+assistant session, opens it, clicks the copy button, and asserts the exact response markdown reaches the (stubbed) clipboard + the button enters its copied state; **could not run locally** (org egress policy 403s the Electron binary download — same block as prior rounds). |
| 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-08 | **Merged** into `main` (2026-07-06). 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. |
Expand Down
Loading