diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5db96b73fc6b8..b0035cdcb7fb4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1985,6 +1985,7 @@ export default function ChatView(props: ChatViewProps) { threadRef: activeThreadRef, preview: canvasPreview, detailsCardInlineOpen: threadPanelInlineOpen, + centerChatBesideDetails: settings.centerChatBesideDetails, }).detailsCardMode, ); const threadPanelOpen = useRightPanelStore((state) => diff --git a/apps/web/src/components/chat/ChatCanvas.tsx b/apps/web/src/components/chat/ChatCanvas.tsx index 7848009308750..56a22ddccf29d 100644 --- a/apps/web/src/components/chat/ChatCanvas.tsx +++ b/apps/web/src/components/chat/ChatCanvas.tsx @@ -1,5 +1,6 @@ import { useLayoutEffect, useRef, useState, type ComponentProps, type CSSProperties } from "react"; import { useStore } from "zustand"; +import { useClientSettings } from "../../hooks/useSettings"; import { ChatCanvasContext } from "./ChatCanvasContext"; import type { ChatCanvasPreview } from "./chatCanvasLayout"; import { resolveChatCanvasModel, type ChatCanvasStore } from "./canvasStore"; @@ -59,6 +60,7 @@ export function ChatCanvas({ composerOverlayElement: HTMLElement | null; detailsCardTopInset?: number; }) { + const centerChatBesideDetails = useClientSettings((settings) => settings.centerChatBesideDetails); const elementRef = useRef(null); const widthProbeRef = useRef(null); const [timelineElement, registerTimeline] = useState(null); @@ -145,6 +147,7 @@ export function ChatCanvas({ threadRef, preview, detailsCardInlineOpen, + centerChatBesideDetails, }); const context = { container: model.container, diff --git a/apps/web/src/components/chat/canvasStore.test.ts b/apps/web/src/components/chat/canvasStore.test.ts index c7620f1db5c87..a1d656b7a0c12 100644 --- a/apps/web/src/components/chat/canvasStore.test.ts +++ b/apps/web/src/components/chat/canvasStore.test.ts @@ -31,9 +31,10 @@ const preview: ChatCanvasPreview = { describe("chat canvas model", () => { it("shows the details card inline only where the canvas fits it beside the chat", () => { - const resolve = (width: number, narrowestWidth = width) => + const resolve = (width: number, centerChatBesideDetails = false, narrowestWidth = width) => resolveChatCanvasModel({ measurements: { ...measurements(width), narrowestWidth }, + centerChatBesideDetails, detailsCardMeasurement: null, threadRef, preview: null, @@ -41,13 +42,43 @@ describe("chat canvas model", () => { }).detailsCardMode; expect(resolve(1400)).toBe("inline"); expect(resolve(900)).toBe("popover"); - expect(resolve(1400, 900)).toBe("popover"); + expect(resolve(1400, true)).toBe("inline"); + expect(resolve(900, true)).toBe("popover"); + expect(resolve(1400, false, 900)).toBe("popover"); + expect(resolve(1400, true, 900)).toBe("popover"); + }); + + it("centers beside details only when opted in and an inline card is shown", () => { + for (const [width, maxChatWidth, defaultLeft, besideLeft] of [ + [1500, 736, 382, 236], + [2200, 736, 732, 586], + [2200, 1024, 588, 442], + [1108, 736, 48, 48], + [1012, 736, 48, 48], + [1000, 736, 132, 132], + [1500, 10000, 48, 48], + ] as const) { + const resolve = (centerChatBesideDetails: boolean, detailsCardInlineOpen = true) => + resolveChatCanvasModel({ + measurements: { ...measurements(width), maxChatWidth }, + detailsCardMeasurement: { threadRef, width: 280, fullContentHeight: 300 }, + threadRef, + preview: null, + detailsCardInlineOpen, + centerChatBesideDetails, + }); + expect(resolve(false).layout.chat.left).toBe(defaultLeft); + expect(resolve(true).layout.chat.left).toBe(besideLeft); + expect(resolve(true, false)).toEqual(resolve(false, false)); + expect(resolve(true).detailsCardMode).toBe(resolve(false).detailsCardMode); + } }); it("moves the card into a popover when a preview below it leaves too little height", () => { - const resolve = (fullContentHeight: number) => + const resolve = (fullContentHeight: number, centerChatBesideDetails = false) => resolveChatCanvasModel({ measurements: measurements(1100, 300), + centerChatBesideDetails, detailsCardMeasurement: { threadRef, width: 280, fullContentHeight }, threadRef, preview: { ...preview, position: { x: 760, y: 150 }, lastInteraction: "drag" }, @@ -56,6 +87,7 @@ describe("chat canvas model", () => { const model = resolve(400); expect(model.layout.overlapsDetailsCard).toBe(true); expect(model.detailsCardMode).toBe("popover"); + expect(resolve(400, true)).toEqual(model); }); it("ignores a details card measured for another thread", () => { diff --git a/apps/web/src/components/chat/canvasStore.ts b/apps/web/src/components/chat/canvasStore.ts index 6b350cf95f22f..e5ef12b9e8266 100644 --- a/apps/web/src/components/chat/canvasStore.ts +++ b/apps/web/src/components/chat/canvasStore.ts @@ -87,6 +87,7 @@ export function resolveChatCanvasModel({ threadRef, preview, detailsCardInlineOpen, + centerChatBesideDetails = false, }: { measurements: ChatCanvasMeasurements; detailsCardMeasurement: DetailsCardMeasurement | null; @@ -94,6 +95,7 @@ export function resolveChatCanvasModel({ threadRef: ScopedThreadRef | null; preview: ChatCanvasPreview | null; detailsCardInlineOpen: boolean; + centerChatBesideDetails?: boolean; }) { const container = { width: measurements.width, height: measurements.height }; const { findBar } = measurements; @@ -137,6 +139,7 @@ export function resolveChatCanvasModel({ maxChatWidth: measurements.maxChatWidth, minChatWidth: measurements.minChatWidth, composerHeight: measurements.composerHeight, + centerChatBesideDetails, detailsCard, findBar, }); diff --git a/apps/web/src/components/chat/chatCanvasLayout.test.ts b/apps/web/src/components/chat/chatCanvasLayout.test.ts index 2f9e82a9e600b..9730b993c4f5d 100644 --- a/apps/web/src/components/chat/chatCanvasLayout.test.ts +++ b/apps/web/src/components/chat/chatCanvasLayout.test.ts @@ -338,6 +338,23 @@ describe("workspace card beside chat", () => { it("keeps chat centered while the card fits beside it", () => { expect(withCard(1500).chat).toEqual({ left: 382, width: 736, insetStart: 0, insetEnd: 0 }); }); + it("centers beside the card when opted in, retaining preview clearance", () => { + const result = resolveChatCanvasLayout({ + container: { width: 1584, height: 988 }, + maxChatWidth: 736, + detailsCard: { left: 1260, right: 1572, bottom: 339 }, + preview: { + ...preview, + width: 240, + source: { width: 240, height: 365 }, + position: { x: 1260, y: 240 }, + }, + centerChatBesideDetails: true, + }); + expect(result.chat).toEqual({ left: 262, width: 736, insetStart: 0, insetEnd: 324 }); + expect(result.frame).toEqual({ x: 1260, y: 351, width: 240, height: 365 }); + expect(result.overlapsDetailsCard).toBe(false); + }); it("narrows chat only after it reaches the left padding", () => { expect(withCard(1108).chat).toMatchObject({ left: 48, width: 736 }); expect(withCard(1028).chat).toEqual({ left: 48, width: 656, insetStart: 0, insetEnd: 276 }); diff --git a/apps/web/src/components/chat/chatCanvasLayout.ts b/apps/web/src/components/chat/chatCanvasLayout.ts index 77e276ee817f4..d92d355393f7a 100644 --- a/apps/web/src/components/chat/chatCanvasLayout.ts +++ b/apps/web/src/components/chat/chatCanvasLayout.ts @@ -29,6 +29,7 @@ export function resolveChatCanvasLayout({ minChatWidth = 640, composerHeight = 0, detailsCard = null, + centerChatBesideDetails = false, findBar = null, }: { container: PreviewMiniPlayerSize; @@ -38,6 +39,7 @@ export function resolveChatCanvasLayout({ minChatWidth?: number; composerHeight?: number; detailsCard?: PreviewMiniPlayerObstacles["detailsCard"]; + centerChatBesideDetails?: boolean; findBar?: PreviewMiniPlayerObstacles["detailsCard"]; }) { const centeredWidth = Math.max(0, Math.min(maxChatWidth, container.width - padding * 2)); @@ -45,8 +47,9 @@ export function resolveChatCanvasLayout({ ? detailsCard.left - DETAILS_CARD_CLEARANCE : container.width - padding; const normalWidth = Math.max(0, Math.min(centeredWidth, laneRight - padding)); - const canvasCenterLeft = (container.width - normalWidth) / 2; - const normalLeft = Math.max(padding, Math.min(canvasCenterLeft, laneRight - normalWidth)); + const centerWidth = centerChatBesideDetails && detailsCard ? detailsCard.left : container.width; + const centerLeft = (centerWidth - normalWidth) / 2; + const normalLeft = Math.max(padding, Math.min(centerLeft, laneRight - normalWidth)); let chat = { left: normalLeft, width: normalWidth, diff --git a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx index 00783b085d609..4f1bd2f4f825b 100644 --- a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx +++ b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx @@ -289,6 +289,32 @@ export function AppearanceSettingsPanel() { } /> + + updateSettings({ + centerChatBesideDetails: DEFAULT_UNIFIED_SETTINGS.centerChatBesideDetails, + }) + } + /> + ) : null + } + control={ + + updateSettings({ centerChatBesideDetails }) + } + aria-label="Center chat beside thread details" + /> + } + /> void) { ? ["Diff colors"] : []), ...(settings.chatWidth !== DEFAULT_UNIFIED_SETTINGS.chatWidth ? ["Chat width"] : []), + ...(settings.centerChatBesideDetails !== DEFAULT_UNIFIED_SETTINGS.centerChatBesideDetails + ? ["Center chat beside thread details"] + : []), ...(settings.effortDisplay !== DEFAULT_UNIFIED_SETTINGS.effortDisplay ? ["Effort display"] : []), @@ -652,6 +655,7 @@ export function useSettingsRestore(onRestored?: () => void) { appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth, + centerChatBesideDetails: DEFAULT_UNIFIED_SETTINGS.centerChatBesideDetails, effortDisplay: DEFAULT_UNIFIED_SETTINGS.effortDisplay, mathRenderingEnabled: DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled, language: DEFAULT_UNIFIED_SETTINGS.language, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 7d7939e7f21f9..7bd11810052c6 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -347,6 +347,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/appearance", searchTerms: ["wide full width column layout messages composer monitor"], }, + { + id: "center-chat-beside-details", + title: "Center chat beside thread details", + to: "/settings/appearance", + searchTerms: ["center centering alignment free space messages composer layout panel card"], + }, { id: "thread-details-items", title: "Thread details", diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 1db913e38ba3b..325b367db1a3e 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -317,6 +317,16 @@ describe("resolveIdleCompaction", () => { }); }); +describe("ClientSettings chat centering", () => { + it("defaults to canvas centering and preserves an explicit opt-in", () => { + expect(decodeClientSettings({}).centerChatBesideDetails).toBe(false); + expect(decodeClientSettingsPatch({})).not.toHaveProperty("centerChatBesideDetails"); + const patch = decodeClientSettingsPatch({ centerChatBesideDetails: true }); + expect(patch).toEqual({ centerChatBesideDetails: true }); + expect(encodeClientSettings(decodeClientSettings(patch)).centerChatBesideDetails).toBe(true); + }); +}); + describe("ClientSettings notifications", () => { it("requires opt-in when existing settings omit notification preferences", () => { expect(decodeClientSettings({}).notificationMode).toBe("off"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7bbb03595d4bb..0fea4d10c7390 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -411,6 +411,7 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed("red-green" as const)), ), chatWidth: ChatWidth.pipe(Schema.withDecodingDefault(Effect.succeed("comfortable" as const))), + centerChatBesideDetails: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), effortDisplay: EffortDisplay.pipe(Schema.withDecodingDefault(Effect.succeed("label" as const))), loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))), @@ -2036,6 +2037,7 @@ export const ClientSettingsPatch = Schema.Struct({ inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean), diffColorScheme: Schema.optionalKey(DiffColorScheme), chatWidth: Schema.optionalKey(ChatWidth), + centerChatBesideDetails: Schema.optionalKey(Schema.Boolean), effortDisplay: Schema.optionalKey(EffortDisplay), loadBalancingEnabled: Schema.optionalKey(Schema.Boolean), loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights),