From 4d58ec9fbba5d383f6543251f970c555e83a875d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 21 May 2026 11:45:13 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20full-width=20c?= =?UTF-8?q?hat=20transcript=20setting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a persisted appearance setting that lets users expand chat transcripts to the full chat pane width, with backend config persistence, ORPC wiring, settings UI, and layout usage. --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$37.62`_ --- src/browser/components/ChatPane/ChatPane.tsx | 4 +- .../SshPromptDialog/SshPromptDialog.test.tsx | 74 +----- .../Settings/Sections/GeneralSection.test.tsx | 38 ++- .../Settings/Sections/GeneralSection.tsx | 55 ++++- src/browser/features/Tools/BashToolCall.tsx | 8 +- .../hooks/useChatTranscriptFullWidth.test.tsx | 219 ++++++++++++++++++ .../hooks/useChatTranscriptFullWidth.ts | 92 ++++++++ src/browser/stories/mocks/orpc.ts | 10 + src/browser/testUtils.ts | 78 +++++++ .../config/schemas/appConfigOnDisk.test.ts | 7 + src/common/config/schemas/appConfigOnDisk.ts | 1 + src/common/constants/storage.ts | 5 + src/common/orpc/schemas/api.ts | 20 +- src/common/types/project.ts | 2 + src/node/config.test.ts | 41 ++++ src/node/config.ts | 6 + src/node/orpc/router.test.ts | 16 ++ src/node/orpc/router.ts | 14 ++ 18 files changed, 604 insertions(+), 86 deletions(-) create mode 100644 src/browser/hooks/useChatTranscriptFullWidth.test.tsx create mode 100644 src/browser/hooks/useChatTranscriptFullWidth.ts diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 5cc0557d53..f7e1dea825 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -73,6 +73,7 @@ import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; import type { TerminalSessionCreateOptions } from "@/browser/utils/terminal"; import { useAPI } from "@/browser/contexts/API"; +import { useChatTranscriptFullWidth } from "@/browser/hooks/useChatTranscriptFullWidth"; import { useReviews } from "@/browser/hooks/useReviews"; import { ReviewsBanner } from "../ReviewsBanner/ReviewsBanner"; import type { ReviewNoteData } from "@/common/types/review"; @@ -187,6 +188,7 @@ export const ChatPane: React.FC = (props) => { workspaceState, immersiveHidden = false, } = props; + const chatTranscriptFullWidth = useChatTranscriptFullWidth(); const { api } = useAPI(); const { workspaceMetadata } = useWorkspaceContext(); const chatAreaRef = useRef(null); @@ -1044,7 +1046,7 @@ export const ChatPane: React.FC = (props) => { >
diff --git a/src/browser/components/SshPromptDialog/SshPromptDialog.test.tsx b/src/browser/components/SshPromptDialog/SshPromptDialog.test.tsx index 37f516956d..6c15389a02 100644 --- a/src/browser/components/SshPromptDialog/SshPromptDialog.test.tsx +++ b/src/browser/components/SshPromptDialog/SshPromptDialog.test.tsx @@ -3,6 +3,10 @@ import "../../../../tests/ui/dom"; import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import type { SshPromptEvent, SshPromptRequest } from "@/common/orpc/schemas/ssh"; +import { + createControllableAsyncIterable, + type ControllableAsyncIterable, +} from "@/browser/testUtils"; import type { ReactNode } from "react"; import { installDom } from "../../../../tests/ui/dom"; @@ -25,79 +29,15 @@ void mock.module("@/browser/components/Dialog/Dialog", () => ({ import { SshPromptDialog } from "../SshPromptDialog/SshPromptDialog"; -interface ControlledSubscription { - iterable: AsyncIterable; - push: (value: T) => void; - close: () => void; +interface ControlledSubscription extends ControllableAsyncIterable { returnSpy: ReturnType; } function createMockIterableSubscription(): ControlledSubscription { - const buffered: T[] = []; - const pending: Array<(result: IteratorResult) => void> = []; - let closed = false; - - const doneResult = (): IteratorResult => ({ - value: undefined as unknown as T, - done: true, - }); - - const flushDone = () => { - while (pending.length > 0) { - const resolve = pending.shift(); - resolve?.(doneResult()); - } - }; - - const returnSpy = mock((_value?: unknown) => { - closed = true; - flushDone(); - return Promise.resolve(doneResult()); - }); - - const iterator: AsyncIterator = { - next() { - if (closed) { - return Promise.resolve(doneResult()); - } - - if (buffered.length > 0) { - return Promise.resolve({ value: buffered.shift()!, done: false }); - } - - return new Promise((resolve) => { - pending.push(resolve); - }); - }, - return: returnSpy, - }; - + const returnSpy = mock(() => undefined); return { - iterable: { - [Symbol.asyncIterator]: () => iterator, - }, + ...createControllableAsyncIterable({ onReturn: returnSpy }), returnSpy, - push(value: T) { - if (closed) { - return; - } - - const resolve = pending.shift(); - if (resolve) { - resolve({ value, done: false }); - return; - } - - buffered.push(value); - }, - close() { - if (closed) { - return; - } - - closed = true; - flushDone(); - }, }; } diff --git a/src/browser/features/Settings/Sections/GeneralSection.test.tsx b/src/browser/features/Settings/Sections/GeneralSection.test.tsx index 8b144e8ba9..e0fa9a38cb 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.test.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.test.tsx @@ -17,6 +17,7 @@ import { interface MockConfig { coderWorkspaceArchiveBehavior: CoderWorkspaceArchiveBehavior; worktreeArchiveBehavior: WorktreeArchiveBehavior; + chatTranscriptFullWidth: boolean; llmDebugLogs: boolean; } @@ -27,6 +28,7 @@ interface MockAPIClient { coderWorkspaceArchiveBehavior: CoderWorkspaceArchiveBehavior; worktreeArchiveBehavior: WorktreeArchiveBehavior; }) => Promise; + updateChatTranscriptFullWidth: (input: { enabled: boolean }) => Promise; updateLlmDebugLogs: (input: { enabled: boolean }) => Promise; }; server: { @@ -168,6 +170,7 @@ import { GeneralSection } from "./GeneralSection"; interface RenderGeneralSectionOptions { coderWorkspaceArchiveBehavior?: CoderWorkspaceArchiveBehavior; worktreeArchiveBehavior?: WorktreeArchiveBehavior; + chatTranscriptFullWidth?: boolean; } interface MockAPISetup { @@ -181,12 +184,16 @@ interface MockAPISetup { }) => Promise > >; + updateChatTranscriptFullWidthMock: ReturnType< + typeof mock<(input: { enabled: boolean }) => Promise> + >; } function createMockAPI(configOverrides: Partial = {}): MockAPISetup { const config: MockConfig = { coderWorkspaceArchiveBehavior: DEFAULT_CODER_ARCHIVE_BEHAVIOR, worktreeArchiveBehavior: DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR, + chatTranscriptFullWidth: false, llmDebugLogs: false, ...configOverrides, }; @@ -204,11 +211,18 @@ function createMockAPI(configOverrides: Partial = {}): MockAPISetup } ); + const updateChatTranscriptFullWidthMock = mock(({ enabled }: { enabled: boolean }) => { + config.chatTranscriptFullWidth = enabled; + + return Promise.resolve(); + }); + return { api: { config: { getConfig: getConfigMock, updateCoderPrefs: updateCoderPrefsMock, + updateChatTranscriptFullWidth: updateChatTranscriptFullWidthMock, updateLlmDebugLogs: mock(({ enabled }: { enabled: boolean }) => { config.llmDebugLogs = enabled; @@ -226,6 +240,7 @@ function createMockAPI(configOverrides: Partial = {}): MockAPISetup }, getConfigMock, updateCoderPrefsMock, + updateChatTranscriptFullWidthMock, }; } @@ -248,7 +263,8 @@ describe("GeneralSection", () => { }); function renderGeneralSection(options: RenderGeneralSectionOptions = {}) { - const { api, updateCoderPrefsMock } = createMockAPI({ + const { api, updateCoderPrefsMock, updateChatTranscriptFullWidthMock } = createMockAPI({ + chatTranscriptFullWidth: options.chatTranscriptFullWidth, coderWorkspaceArchiveBehavior: options.coderWorkspaceArchiveBehavior, worktreeArchiveBehavior: options.worktreeArchiveBehavior, }); @@ -260,7 +276,7 @@ describe("GeneralSection", () => { ); - return { updateCoderPrefsMock, view }; + return { updateCoderPrefsMock, updateChatTranscriptFullWidthMock, view }; } function getSelectTrigger(view: ReturnType, label: string): HTMLElement { @@ -319,6 +335,24 @@ describe("GeneralSection", () => { ); }); + test("loads and persists the full-width chat transcript toggle", async () => { + const { updateChatTranscriptFullWidthMock, view } = renderGeneralSection({ + chatTranscriptFullWidth: true, + }); + + const toggle = view.getByRole("switch", { name: "Toggle full-width chat transcript" }); + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + }); + + fireEvent.click(toggle); + + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("false"); + expect(updateChatTranscriptFullWidthMock).toHaveBeenCalledWith({ enabled: false }); + }); + }); + test("renders the worktree archive behavior copy and loads the saved value", async () => { const { view } = renderGeneralSection({ coderWorkspaceArchiveBehavior: "delete", diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index 2528a4626c..b5a9d59feb 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -9,7 +9,7 @@ import { } from "@/browser/components/SelectPrimitive/SelectPrimitive"; import { Input } from "@/browser/components/Input/Input"; import { Switch } from "@/browser/components/Switch/Switch"; -import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { updatePersistedState, usePersistedState } from "@/browser/hooks/usePersistedState"; import { useAPI } from "@/browser/contexts/API"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { @@ -20,6 +20,7 @@ import { LAUNCH_BEHAVIOR_KEY, BASH_COLLAPSED_SUMMARY_MODE_KEY, BASH_COLLAPSED_SUMMARY_MODES, + CHAT_TRANSCRIPT_FULL_WIDTH_KEY, DEFAULT_BASH_COLLAPSED_SUMMARY_MODE, normalizeBashCollapsedSummaryMode, type BashCollapsedSummaryMode, @@ -224,6 +225,7 @@ export function GeneralSection() { DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR ); const [archiveSettingsLoaded, setArchiveSettingsLoaded] = useState(false); + const [chatTranscriptFullWidth, setChatTranscriptFullWidth] = useState(false); const [llmDebugLogs, setLlmDebugLogs] = useState(false); const archiveBehaviorLoadNonceRef = useRef(0); const archiveBehaviorRef = useRef(DEFAULT_CODER_ARCHIVE_BEHAVIOR); @@ -231,11 +233,13 @@ export function GeneralSection() { DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR ); + const chatTranscriptFullWidthLoadNonceRef = useRef(0); const llmDebugLogsLoadNonceRef = useRef(0); // updateCoderPrefs writes config.json on the backend. Serialize (and coalesce) updates so rapid // selections can't race and persist a stale value via out-of-order writes. const archiveBehaviorUpdateChainRef = useRef>(Promise.resolve()); + const chatTranscriptFullWidthUpdateChainRef = useRef>(Promise.resolve()); const llmDebugLogsUpdateChainRef = useRef>(Promise.resolve()); const archiveBehaviorPendingUpdateRef = useRef( undefined @@ -251,6 +255,7 @@ export function GeneralSection() { setArchiveSettingsLoaded(false); const archiveBehaviorNonce = ++archiveBehaviorLoadNonceRef.current; + const chatTranscriptFullWidthNonce = ++chatTranscriptFullWidthLoadNonceRef.current; const llmDebugLogsNonce = ++llmDebugLogsLoadNonceRef.current; void api.config @@ -274,7 +279,16 @@ export function GeneralSection() { setArchiveSettingsLoaded(true); } - // Use an independent nonce so debug-log toggles do not discard archive-setting updates. + // Use independent nonces so appearance/debug toggles do not discard archive updates. + if (chatTranscriptFullWidthNonce === chatTranscriptFullWidthLoadNonceRef.current) { + const enabled = cfg.chatTranscriptFullWidth === true; + setChatTranscriptFullWidth(enabled); + updatePersistedState( + CHAT_TRANSCRIPT_FULL_WIDTH_KEY, + enabled ? true : undefined + ); + } + if (llmDebugLogsNonce === llmDebugLogsLoadNonceRef.current) { setLlmDebugLogs(cfg.llmDebugLogs === true); } @@ -361,6 +375,29 @@ export function GeneralSection() { [api, archiveSettingsLoaded, queueArchiveBehaviorUpdate] ); + const handleChatTranscriptFullWidthChange = (checked: boolean) => { + // Invalidate any in-flight config load so it does not overwrite the user's selection. + chatTranscriptFullWidthLoadNonceRef.current++; + setChatTranscriptFullWidth(checked); + updatePersistedState( + CHAT_TRANSCRIPT_FULL_WIDTH_KEY, + checked ? true : undefined + ); + + if (!api?.config?.updateChatTranscriptFullWidth) { + return; + } + + chatTranscriptFullWidthUpdateChainRef.current = chatTranscriptFullWidthUpdateChainRef.current + .catch(() => { + // Best-effort only. + }) + .then(() => api.config.updateChatTranscriptFullWidth({ enabled: checked })) + .catch(() => { + // Best-effort persistence. + }); + }; + const handleLlmDebugLogsChange = (checked: boolean) => { // Invalidate any in-flight debug-log load so it doesn't overwrite the user's selection. llmDebugLogsLoadNonceRef.current++; @@ -521,6 +558,20 @@ export function GeneralSection() {
+
+
+
Full-width chat transcript
+
+ Let messages use the full chat pane instead of the default readable column. +
+
+ +
+
Collapsed bash summaries
diff --git a/src/browser/features/Tools/BashToolCall.tsx b/src/browser/features/Tools/BashToolCall.tsx index 9cfaf04cbe..81335bf7ae 100644 --- a/src/browser/features/Tools/BashToolCall.tsx +++ b/src/browser/features/Tools/BashToolCall.tsx @@ -161,14 +161,10 @@ export const BashToolCall: React.FC = ({ {bashCollapsedSummary.kind === "intent-command" ? ( - // Two lines: intent (primary) on top, command (muted mono) below. - // The duration chip sits on the command row so it inherits the same - // line-height and stays vertically centered on the command, not - // floating between the two lines. - + {bashCollapsedSummary.intent} - + {bashCollapsedSummary.command} {!isBackground && ( diff --git a/src/browser/hooks/useChatTranscriptFullWidth.test.tsx b/src/browser/hooks/useChatTranscriptFullWidth.test.tsx new file mode 100644 index 0000000000..013b80fa8e --- /dev/null +++ b/src/browser/hooks/useChatTranscriptFullWidth.test.tsx @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { GlobalWindow } from "happy-dom"; +import type React from "react"; + +import { APIProvider, type APIClient } from "@/browser/contexts/API"; +import { createControllableAsyncIterable } from "@/browser/testUtils"; +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { CHAT_TRANSCRIPT_FULL_WIDTH_KEY } from "@/common/constants/storage"; + +import { useChatTranscriptFullWidth } from "./useChatTranscriptFullWidth"; + +interface TranscriptWidthConfig { + chatTranscriptFullWidth: boolean; +} + +function createConfigEventStream() { + const returnMock = mock(() => undefined); + const stream = createControllableAsyncIterable({ onReturn: returnMock }); + + return { + emit(value: unknown = Symbol("config-change")) { + stream.push(value); + }, + iterator: stream.iterable, + returnMock, + }; +} + +function createWrapper(client: APIClient): React.FC<{ children: React.ReactNode }> { + return function Wrapper(props) { + return {props.children}; + }; +} + +describe("useChatTranscriptFullWidth", () => { + beforeEach(() => { + globalThis.window = new GlobalWindow({ url: "https://mux.example.com/" }) as unknown as Window & + typeof globalThis; + globalThis.document = globalThis.window.document; + }); + + afterEach(() => { + cleanup(); + updatePersistedState(CHAT_TRANSCRIPT_FULL_WIDTH_KEY, undefined); + }); + + test("uses the cached preference until backend config resolves", async () => { + updatePersistedState(CHAT_TRANSCRIPT_FULL_WIDTH_KEY, true); + const stream = createConfigEventStream(); + const getConfigMock = mock(() => + Promise.resolve({ chatTranscriptFullWidth: true }) + ); + const onConfigChangedMock = mock(() => Promise.resolve(stream.iterator)); + const client = { + config: { + getConfig: getConfigMock, + onConfigChanged: onConfigChangedMock, + }, + } as unknown as APIClient; + + const { result, unmount } = renderHook(() => useChatTranscriptFullWidth(), { + wrapper: createWrapper(client), + }); + + expect(result.current).toBe(true); + await waitFor(() => { + expect(getConfigMock).toHaveBeenCalledTimes(1); + expect(onConfigChangedMock).toHaveBeenCalledTimes(1); + }); + + unmount(); + + await waitFor(() => { + expect(stream.returnMock).toHaveBeenCalled(); + }); + }); + + test("ignores stale config fetches after a newer subscription refresh", async () => { + const firstFetch = Promise.withResolvers(); + const secondFetch = Promise.withResolvers(); + const stream = createConfigEventStream(); + const getConfigMock = mock(() => { + if (getConfigMock.mock.calls.length === 1) { + return firstFetch.promise; + } + + return secondFetch.promise; + }); + const client = { + config: { + getConfig: getConfigMock, + onConfigChanged: mock(() => Promise.resolve(stream.iterator)), + }, + } as unknown as APIClient; + + const { result } = renderHook(() => useChatTranscriptFullWidth(), { + wrapper: createWrapper(client), + }); + + expect(result.current).toBe(false); + await waitFor(() => { + expect(getConfigMock).toHaveBeenCalledTimes(1); + }); + + act(() => { + stream.emit(); + }); + await waitFor(() => { + expect(getConfigMock).toHaveBeenCalledTimes(2); + }); + + await act(async () => { + secondFetch.resolve({ chatTranscriptFullWidth: true }); + await secondFetch.promise; + }); + await waitFor(() => { + expect(result.current).toBe(true); + }); + + await act(async () => { + firstFetch.resolve({ chatTranscriptFullWidth: false }); + await firstFetch.promise; + }); + + expect(result.current).toBe(true); + }); + + test("ignores invalid cached preference values", () => { + updatePersistedState(CHAT_TRANSCRIPT_FULL_WIDTH_KEY, "false"); + const getConfig = Promise.withResolvers(); + const client = { + config: { + getConfig: mock(() => getConfig.promise), + onConfigChanged: mock(() => Promise.resolve(createConfigEventStream().iterator)), + }, + } as unknown as APIClient; + + const { result } = renderHook(() => useChatTranscriptFullWidth(), { + wrapper: createWrapper(client), + }); + + expect(result.current).toBe(false); + }); + + test("responds to persisted preference updates while mounted", async () => { + const getConfig = Promise.withResolvers(); + const client = { + config: { + getConfig: mock(() => getConfig.promise), + onConfigChanged: mock(() => Promise.resolve(createConfigEventStream().iterator)), + }, + } as unknown as APIClient; + + const { result } = renderHook(() => useChatTranscriptFullWidth(), { + wrapper: createWrapper(client), + }); + + expect(result.current).toBe(false); + + act(() => { + updatePersistedState(CHAT_TRANSCRIPT_FULL_WIDTH_KEY, true); + }); + + await waitFor(() => { + expect(result.current).toBe(true); + }); + }); + + test("caches backend config for the next mount", async () => { + const firstFetch = Promise.withResolvers(); + const secondFetch = Promise.withResolvers(); + const getConfigMock = mock(() => { + if (getConfigMock.mock.calls.length === 1) { + return firstFetch.promise; + } + + return secondFetch.promise; + }); + const client = { + config: { + getConfig: getConfigMock, + onConfigChanged: mock(() => Promise.resolve(createConfigEventStream().iterator)), + }, + } as unknown as APIClient; + + const firstRender = renderHook(() => useChatTranscriptFullWidth(), { + wrapper: createWrapper(client), + }); + + expect(firstRender.result.current).toBe(false); + await act(async () => { + firstFetch.resolve({ chatTranscriptFullWidth: true }); + await firstFetch.promise; + }); + await waitFor(() => { + expect(firstRender.result.current).toBe(true); + }); + + firstRender.unmount(); + + const secondRender = renderHook(() => useChatTranscriptFullWidth(), { + wrapper: createWrapper(client), + }); + + expect(secondRender.result.current).toBe(true); + }); + + test("preserves the last known value while API config is unavailable", () => { + updatePersistedState(CHAT_TRANSCRIPT_FULL_WIDTH_KEY, true); + const client = { config: {} } as unknown as APIClient; + + const { result } = renderHook(() => useChatTranscriptFullWidth(), { + wrapper: createWrapper(client), + }); + + expect(result.current).toBe(true); + }); +}); diff --git a/src/browser/hooks/useChatTranscriptFullWidth.ts b/src/browser/hooks/useChatTranscriptFullWidth.ts new file mode 100644 index 0000000000..406759753d --- /dev/null +++ b/src/browser/hooks/useChatTranscriptFullWidth.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef } from "react"; + +import { useAPI } from "@/browser/contexts/API"; +import { updatePersistedState, usePersistedState } from "@/browser/hooks/usePersistedState"; +import { CHAT_TRANSCRIPT_FULL_WIDTH_KEY } from "@/common/constants/storage"; + +/** Seeded from local cache to avoid a layout flash before the backend responds. */ +export function useChatTranscriptFullWidth(): boolean { + const { api } = useAPI(); + const fetchVersionRef = useRef(0); + const [rawChatTranscriptFullWidth] = usePersistedState( + CHAT_TRANSCRIPT_FULL_WIDTH_KEY, + false, + { listener: true } + ); + const chatTranscriptFullWidth = rawChatTranscriptFullWidth === true; + const chatTranscriptFullWidthRef = useRef(chatTranscriptFullWidth); + + useEffect(() => { + chatTranscriptFullWidthRef.current = chatTranscriptFullWidth; + }, [chatTranscriptFullWidth]); + + useEffect(() => { + const getConfig = api?.config?.getConfig; + if (!getConfig) { + return; + } + + const abortController = new AbortController(); + const { signal } = abortController; + let iterator: AsyncIterator | null = null; + + const setSyncedValue = (enabled: boolean) => { + if (chatTranscriptFullWidthRef.current === enabled) { + return; + } + + chatTranscriptFullWidthRef.current = enabled; + updatePersistedState( + CHAT_TRANSCRIPT_FULL_WIDTH_KEY, + enabled ? true : undefined + ); + }; + + const refresh = () => { + const fetchVersion = ++fetchVersionRef.current; + getConfig() + .then((config) => { + if (!signal.aborted && fetchVersion === fetchVersionRef.current) { + setSyncedValue(config.chatTranscriptFullWidth === true); + } + }) + .catch(() => { + // Keep the current preference on failure. + }); + }; + + refresh(); + + const onConfigChanged = api?.config?.onConfigChanged; + if (onConfigChanged) { + const runSubscription = async () => { + try { + const subscribedIterator = await onConfigChanged(undefined, { signal }); + if (signal.aborted) { + void subscribedIterator.return?.(); + return; + } + + iterator = subscribedIterator; + for await (const _ of subscribedIterator) { + if (signal.aborted) { + break; + } + refresh(); + } + } catch { + // Subscription errors are non-fatal. + } + }; + + void runSubscription(); + } + + return () => { + abortController.abort(); + void iterator?.return?.(); + }; + }, [api]); + + return chatTranscriptFullWidth; +} diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 4488f9c943..1d4b48bd51 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -132,6 +132,8 @@ export interface MockORPCClientOptions { coderWorkspaceArchiveBehavior?: CoderWorkspaceArchiveBehavior; /** What to do with mux-managed worktrees when archiving a chat. */ worktreeArchiveBehavior?: WorktreeArchiveBehavior; + /** Initial full-width transcript toggle for config.getConfig */ + chatTranscriptFullWidth?: boolean; /** Initial runtime enablement for config.getConfig */ runtimeEnablement?: Record; /** Initial default runtime for config.getConfig (global) */ @@ -369,6 +371,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl agentAiDefaults: initialAgentAiDefaults, coderWorkspaceArchiveBehavior: initialCoderWorkspaceArchiveBehavior = "stop", worktreeArchiveBehavior: initialWorktreeArchiveBehavior = "keep", + chatTranscriptFullWidth: initialChatTranscriptFullWidth = false, runtimeEnablement: initialRuntimeEnablement, defaultRuntime: initialDefaultRuntime, onePasswordAccountName: initialOnePasswordAccountName = null, @@ -501,6 +504,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl let muxGatewayModels: string[] | undefined = undefined; let coderWorkspaceArchiveBehavior = initialCoderWorkspaceArchiveBehavior; let worktreeArchiveBehavior = initialWorktreeArchiveBehavior; + let chatTranscriptFullWidth = initialChatTranscriptFullWidth; let runtimeEnablement: Record = initialRuntimeEnablement ?? { local: true, worktree: true, @@ -710,6 +714,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl heartbeatDefaultIntervalMs, imageGeneration, goalDefaults, + chatTranscriptFullWidth, muxGovernorEnrolled, llmDebugLogs: false, }), @@ -781,6 +786,11 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl notifyConfigChanged(); return Promise.resolve(undefined); }, + updateChatTranscriptFullWidth: (input: { enabled: boolean }) => { + chatTranscriptFullWidth = input.enabled; + notifyConfigChanged(); + return Promise.resolve(undefined); + }, updateCoderPrefs: (input: { coderWorkspaceArchiveBehavior: CoderWorkspaceArchiveBehavior; worktreeArchiveBehavior: WorktreeArchiveBehavior; diff --git a/src/browser/testUtils.ts b/src/browser/testUtils.ts index f309093bcf..1205e19e55 100644 --- a/src/browser/testUtils.ts +++ b/src/browser/testUtils.ts @@ -25,3 +25,81 @@ export type RecursivePartial = { ? RecursivePartial : T[P]; }; + +export interface ControllableAsyncIterable { + iterable: AsyncIterableIterator; + push(value: T): void; + close(): void; +} + +export function createControllableAsyncIterable( + options: { + onReturn?: () => void; + } = {} +): ControllableAsyncIterable { + const buffered: T[] = []; + const pending: Array<(result: IteratorResult) => void> = []; + let closed = false; + + const doneResult = (): IteratorResult => ({ + value: undefined as T, + done: true, + }); + + const flushDone = () => { + while (pending.length > 0) { + pending.shift()?.(doneResult()); + } + }; + + const close = () => { + if (closed) { + return; + } + + closed = true; + flushDone(); + }; + + const iterable: AsyncIterableIterator = { + [Symbol.asyncIterator]() { + return iterable; + }, + next() { + if (closed) { + return Promise.resolve(doneResult()); + } + + if (buffered.length > 0) { + return Promise.resolve({ value: buffered.shift() as T, done: false }); + } + + return new Promise((resolve) => { + pending.push(resolve); + }); + }, + return() { + options.onReturn?.(); + close(); + return Promise.resolve(doneResult()); + }, + }; + + return { + iterable, + push(value: T) { + if (closed) { + return; + } + + const resolve = pending.shift(); + if (resolve) { + resolve({ value, done: false }); + return; + } + + buffered.push(value); + }, + close, + }; +} diff --git a/src/common/config/schemas/appConfigOnDisk.test.ts b/src/common/config/schemas/appConfigOnDisk.test.ts index 1ff8a3adf3..e88deab005 100644 --- a/src/common/config/schemas/appConfigOnDisk.test.ts +++ b/src/common/config/schemas/appConfigOnDisk.test.ts @@ -15,6 +15,13 @@ describe("AppConfigOnDiskSchema", () => { expect(AppConfigOnDiskSchema.safeParse(valid).success).toBe(true); }); + it("validates the full-width chat transcript flag", () => { + expect(AppConfigOnDiskSchema.safeParse({ chatTranscriptFullWidth: true }).success).toBe(true); + expect(AppConfigOnDiskSchema.safeParse({ chatTranscriptFullWidth: "true" }).success).toBe( + false + ); + }); + it("validates taskSettings with limits", () => { const valid = { taskSettings: { diff --git a/src/common/config/schemas/appConfigOnDisk.ts b/src/common/config/schemas/appConfigOnDisk.ts index 3c05ad233a..3af551ee00 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -90,6 +90,7 @@ export const AppConfigOnDiskSchema = z featureFlagOverrides: z.record(z.string(), FeatureFlagOverrideSchema).optional(), layoutPresets: z.unknown().optional(), taskSettings: TaskSettingsSchema.optional(), + chatTranscriptFullWidth: z.boolean().optional(), muxGatewayEnabled: z.boolean().optional(), llmDebugLogs: z.boolean().optional(), heartbeatDefaultPrompt: z.string().optional(), diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 3c9882e5c9..ead2d92cb8 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -82,6 +82,11 @@ export const LAUNCH_BEHAVIOR_KEY = "launchBehavior"; export type LaunchBehavior = "dashboard" | "new-chat" | "last-workspace"; +/** + * Synchronous mirror for the backend full-width transcript preference. + */ +export const CHAT_TRANSCRIPT_FULL_WIDTH_KEY = "chatTranscriptFullWidth"; + /** * Get the localStorage key for expanded projects in sidebar (global) * Format: "expandedProjects" diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 77068e7f87..dbdfd4badd 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1961,6 +1961,15 @@ const GoalDefaultsConfigSchema = z.object({ .default(DEFAULT_GOAL_DEFAULTS.alwaysRequireExplicitBudget), }); +const booleanToggleRoute = { + input: z + .object({ + enabled: z.boolean(), + }) + .strict(), + output: z.void(), +}; + export const config = { getConfig: { input: z.void(), @@ -1987,6 +1996,7 @@ export const config = { // Mux Governor enrollment status (safe fields only - token never exposed) muxGovernorUrl: z.string().nullable(), muxGovernorEnrolled: z.boolean(), + chatTranscriptFullWidth: z.boolean(), llmDebugLogs: z.boolean(), heartbeatDefaultPrompt: z.string().optional(), heartbeatDefaultIntervalMs: z.number().optional(), @@ -2072,14 +2082,8 @@ export const config = { .strict(), output: z.void(), }, - updateLlmDebugLogs: { - input: z - .object({ - enabled: z.boolean(), - }) - .strict(), - output: z.void(), - }, + updateChatTranscriptFullWidth: booleanToggleRoute, + updateLlmDebugLogs: booleanToggleRoute, updateHeartbeatDefaultPrompt: { input: z .object({ diff --git a/src/common/types/project.ts b/src/common/types/project.ts index cf735a605e..9108df732e 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -81,6 +81,8 @@ export interface ProjectsConfig { taskSettings?: TaskSettings; /** UI layout presets + hotkeys (shared via ~/.mux/config.json). */ layoutPresets?: LayoutPresetsConfig; + /** Let chat transcripts use the full chat pane width instead of the default readable column. */ + chatTranscriptFullWidth?: boolean; /** * Mux Gateway routing preferences (shared via ~/.mux/config.json). * Mirrors browser localStorage so switching server ports doesn't reset the UI. diff --git a/src/node/config.test.ts b/src/node/config.test.ts index a619053294..4a0c64f250 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -54,6 +54,47 @@ describe("Config", () => { }); }); + describe("chat transcript settings", () => { + it("persists the full-width transcript flag", async () => { + await config.editConfig((cfg) => { + cfg.chatTranscriptFullWidth = true; + return cfg; + }); + + const restartedConfig = new Config(tempDir); + expect(restartedConfig.loadConfigOrDefault().chatTranscriptFullWidth).toBe(true); + + const raw = JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as { + chatTranscriptFullWidth?: unknown; + }; + expect(raw.chatTranscriptFullWidth).toBe(true); + }); + + it("omits the full-width transcript flag when disabled", async () => { + await config.editConfig((cfg) => { + cfg.chatTranscriptFullWidth = false; + return cfg; + }); + + const raw = JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as { + chatTranscriptFullWidth?: unknown; + }; + expect(raw.chatTranscriptFullWidth).toBeUndefined(); + }); + + it("ignores invalid full-width transcript values on load", () => { + fs.writeFileSync( + path.join(tempDir, "config.json"), + JSON.stringify({ + projects: [], + chatTranscriptFullWidth: "yes", + }) + ); + + expect(config.loadConfigOrDefault().chatTranscriptFullWidth).toBeUndefined(); + }); + }); + describe("api server settings", () => { it("should persist apiServerBindHost, apiServerPort, and apiServerServeWebUi", async () => { await config.editConfig((cfg) => { diff --git a/src/node/config.ts b/src/node/config.ts index 918c172bb6..f9a44c2ebf 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -878,6 +878,7 @@ export class Config { viewedSplashScreens: parsed.viewedSplashScreens, layoutPresets, taskSettings, + chatTranscriptFullWidth: parseOptionalBoolean(parsed.chatTranscriptFullWidth), muxGatewayEnabled, llmDebugLogs: parseOptionalBoolean(parsed.llmDebugLogs), heartbeatDefaultPrompt: parseOptionalNonEmptyString(parsed.heartbeatDefaultPrompt), @@ -954,6 +955,11 @@ export class Config { data.muxGatewayEnabled = muxGatewayEnabled; } + const chatTranscriptFullWidth = parseOptionalBoolean(config.chatTranscriptFullWidth); + if (chatTranscriptFullWidth === true) { + data.chatTranscriptFullWidth = true; + } + const llmDebugLogs = parseOptionalBoolean(config.llmDebugLogs); if (llmDebugLogs !== undefined) { data.llmDebugLogs = llmDebugLogs; diff --git a/src/node/orpc/router.test.ts b/src/node/orpc/router.test.ts index 0791f95de0..ced3ecaf38 100644 --- a/src/node/orpc/router.test.ts +++ b/src/node/orpc/router.test.ts @@ -112,6 +112,22 @@ describe("router config.saveConfig", () => { expect(saved.subagentAiDefaults?.foo).toBeUndefined(); }); + test("persists the full-width chat transcript config flag", async () => { + const client = createRouterClient(router(), { context: createContext() }); + + expect((await client.config.getConfig()).chatTranscriptFullWidth).toBe(false); + + await client.config.updateChatTranscriptFullWidth({ enabled: true }); + + expect((await client.config.getConfig()).chatTranscriptFullWidth).toBe(true); + expect(config.loadConfigOrDefault().chatTranscriptFullWidth).toBe(true); + + await client.config.updateChatTranscriptFullWidth({ enabled: false }); + + expect((await client.config.getConfig()).chatTranscriptFullWidth).toBe(false); + expect(config.loadConfigOrDefault().chatTranscriptFullWidth).toBeUndefined(); + }); + test("preserves optional task settings when a save omits them", async () => { await config.editConfig((current) => ({ ...current, diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index c32aed23ab..c80da15a09 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -698,6 +698,7 @@ export const router = (authToken?: string) => { // Mux Governor enrollment status (safe fields only - token never exposed) muxGovernorUrl, muxGovernorEnrolled, + chatTranscriptFullWidth: config.chatTranscriptFullWidth === true, llmDebugLogs: config.llmDebugLogs === true, heartbeatDefaultPrompt: config.heartbeatDefaultPrompt ?? undefined, heartbeatDefaultIntervalMs: config.heartbeatDefaultIntervalMs ?? undefined, @@ -1109,6 +1110,19 @@ export const router = (authToken?: string) => { // Re-evaluate task queue in case more slots opened up await context.taskService.maybeStartQueuedTasks(); }), + updateChatTranscriptFullWidth: t + .input(schemas.config.updateChatTranscriptFullWidth.input) + .output(schemas.config.updateChatTranscriptFullWidth.output) + .handler(async ({ context, input }) => { + await context.config.editConfig((config) => { + if (input.enabled) { + config.chatTranscriptFullWidth = true; + } else { + delete config.chatTranscriptFullWidth; + } + return config; + }); + }), updateLlmDebugLogs: t .input(schemas.config.updateLlmDebugLogs.input) .output(schemas.config.updateLlmDebugLogs.output) From fa369a0dc8af792c3257146ca17f14d19b71ece9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 21 May 2026 22:31:41 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A4=96=20tests:=20restore=20DOM=20cle?= =?UTF-8?q?anup=20in=20transcript=20width=20hook=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$97.01`_ --- src/browser/hooks/useChatTranscriptFullWidth.test.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/browser/hooks/useChatTranscriptFullWidth.test.tsx b/src/browser/hooks/useChatTranscriptFullWidth.test.tsx index 013b80fa8e..3f833fc331 100644 --- a/src/browser/hooks/useChatTranscriptFullWidth.test.tsx +++ b/src/browser/hooks/useChatTranscriptFullWidth.test.tsx @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; -import { GlobalWindow } from "happy-dom"; import type React from "react"; +import { installDom } from "../../../tests/ui/dom"; import { APIProvider, type APIClient } from "@/browser/contexts/API"; import { createControllableAsyncIterable } from "@/browser/testUtils"; @@ -34,15 +34,17 @@ function createWrapper(client: APIClient): React.FC<{ children: React.ReactNode } describe("useChatTranscriptFullWidth", () => { + let cleanupDom: (() => void) | null = null; + beforeEach(() => { - globalThis.window = new GlobalWindow({ url: "https://mux.example.com/" }) as unknown as Window & - typeof globalThis; - globalThis.document = globalThis.window.document; + cleanupDom = installDom(); }); afterEach(() => { cleanup(); updatePersistedState(CHAT_TRANSCRIPT_FULL_WIDTH_KEY, undefined); + cleanupDom?.(); + cleanupDom = null; }); test("uses the cached preference until backend config resolves", async () => { From 7472960811b1d55a2ef13ef8243a911526adfb12 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 21 May 2026 23:09:56 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A4=96=20fix:=20preserve=20local=20tr?= =?UTF-8?q?anscript=20width=20toggles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$97.01`_ --- .../hooks/useChatTranscriptFullWidth.test.tsx | 76 +++++++++++++++++++ .../hooks/useChatTranscriptFullWidth.ts | 13 ++++ 2 files changed, 89 insertions(+) diff --git a/src/browser/hooks/useChatTranscriptFullWidth.test.tsx b/src/browser/hooks/useChatTranscriptFullWidth.test.tsx index 3f833fc331..7132ed16fa 100644 --- a/src/browser/hooks/useChatTranscriptFullWidth.test.tsx +++ b/src/browser/hooks/useChatTranscriptFullWidth.test.tsx @@ -128,6 +128,82 @@ describe("useChatTranscriptFullWidth", () => { expect(result.current).toBe(true); }); + test("accepts a newer backend refresh after a backend-driven cache update", async () => { + const firstFetch = Promise.withResolvers(); + const secondFetch = Promise.withResolvers(); + const stream = createConfigEventStream(); + const getConfigMock = mock(() => { + if (getConfigMock.mock.calls.length === 1) { + return firstFetch.promise; + } + + return secondFetch.promise; + }); + const client = { + config: { + getConfig: getConfigMock, + onConfigChanged: mock(() => Promise.resolve(stream.iterator)), + }, + } as unknown as APIClient; + + const { result } = renderHook(() => useChatTranscriptFullWidth(), { + wrapper: createWrapper(client), + }); + + await waitFor(() => { + expect(getConfigMock).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + firstFetch.resolve({ chatTranscriptFullWidth: true }); + await firstFetch.promise; + stream.emit(); + }); + await waitFor(() => { + expect(getConfigMock).toHaveBeenCalledTimes(2); + }); + + await act(async () => { + secondFetch.resolve({ chatTranscriptFullWidth: false }); + await secondFetch.promise; + }); + + await waitFor(() => { + expect(result.current).toBe(false); + }); + }); + + test("keeps a local persisted update when an older backend fetch resolves", async () => { + const fetch = Promise.withResolvers(); + const client = { + config: { + getConfig: mock(() => fetch.promise), + onConfigChanged: mock(() => Promise.resolve(createConfigEventStream().iterator)), + }, + } as unknown as APIClient; + + const { result } = renderHook(() => useChatTranscriptFullWidth(), { + wrapper: createWrapper(client), + }); + + expect(result.current).toBe(false); + + act(() => { + updatePersistedState(CHAT_TRANSCRIPT_FULL_WIDTH_KEY, true); + }); + await waitFor(() => { + expect(result.current).toBe(true); + }); + + await act(async () => { + fetch.resolve({ chatTranscriptFullWidth: false }); + await fetch.promise; + }); + + expect(result.current).toBe(true); + expect(window.localStorage.getItem(CHAT_TRANSCRIPT_FULL_WIDTH_KEY)).toBe(JSON.stringify(true)); + }); + test("ignores invalid cached preference values", () => { updatePersistedState(CHAT_TRANSCRIPT_FULL_WIDTH_KEY, "false"); const getConfig = Promise.withResolvers(); diff --git a/src/browser/hooks/useChatTranscriptFullWidth.ts b/src/browser/hooks/useChatTranscriptFullWidth.ts index 406759753d..6bf92b850b 100644 --- a/src/browser/hooks/useChatTranscriptFullWidth.ts +++ b/src/browser/hooks/useChatTranscriptFullWidth.ts @@ -15,8 +15,20 @@ export function useChatTranscriptFullWidth(): boolean { ); const chatTranscriptFullWidth = rawChatTranscriptFullWidth === true; const chatTranscriptFullWidthRef = useRef(chatTranscriptFullWidth); + const persistedValueRef = useRef(chatTranscriptFullWidth); + const backendPersistedValueRef = useRef(undefined); useEffect(() => { + if (persistedValueRef.current !== chatTranscriptFullWidth) { + persistedValueRef.current = chatTranscriptFullWidth; + const backendPersistedValue = backendPersistedValueRef.current; + backendPersistedValueRef.current = undefined; + if (backendPersistedValue !== chatTranscriptFullWidth) { + // Settings writes update persisted state first, so old backend reads must not overwrite them. + fetchVersionRef.current++; + } + } + chatTranscriptFullWidthRef.current = chatTranscriptFullWidth; }, [chatTranscriptFullWidth]); @@ -36,6 +48,7 @@ export function useChatTranscriptFullWidth(): boolean { } chatTranscriptFullWidthRef.current = enabled; + backendPersistedValueRef.current = enabled; updatePersistedState( CHAT_TRANSCRIPT_FULL_WIDTH_KEY, enabled ? true : undefined