From ded6a341f6d69c9e4efcfbaf731728cb7bf40dea Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Thu, 12 Nov 2026 23:59:57 +0530 Subject: [PATCH] feat(web): opt in to centering chat beside thread details --- apps/web/src/components/ChatView.tsx | 1 + apps/web/src/components/chat/ChatCanvas.tsx | 3 ++ .../src/components/chat/canvasStore.test.ts | 35 +++++++++++++++++-- apps/web/src/components/chat/canvasStore.ts | 3 ++ .../components/chat/chatCanvasLayout.test.ts | 17 +++++++++ .../src/components/chat/chatCanvasLayout.ts | 7 ++-- .../settings/AppearanceSettingsPanel.tsx | 26 ++++++++++++++ .../components/settings/SettingsPanels.tsx | 4 +++ .../src/components/settings/settingsSearch.ts | 6 ++++ packages/contracts/src/settings.test.ts | 10 ++++++ packages/contracts/src/settings.ts | 2 ++ 11 files changed, 110 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fdbb91d9073ff..156b3a8c504d3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1961,6 +1961,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 e05d3befcc138..f26723e936c88 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"; @@ -24,6 +25,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); @@ -78,6 +80,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 ad2fe38b044ba..d7d1e6258cab9 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) => + const resolve = (width: number, centerChatBesideDetails = false) => resolveChatCanvasModel({ measurements: measurements(width), + centerChatBesideDetails, detailsCardMeasurement: null, threadRef, preview: null, @@ -41,12 +42,41 @@ describe("chat canvas model", () => { }).detailsCardMode; expect(resolve(1400)).toBe("inline"); expect(resolve(900)).toBe("popover"); + expect(resolve(1400, true)).toBe("inline"); + expect(resolve(900, true)).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" }, @@ -55,6 +85,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 76fb540dc5d41..da61a235bfe22 100644 --- a/apps/web/src/components/chat/canvasStore.ts +++ b/apps/web/src/components/chat/canvasStore.ts @@ -85,6 +85,7 @@ export function resolveChatCanvasModel({ threadRef, preview, detailsCardInlineOpen, + centerChatBesideDetails = false, }: { measurements: ChatCanvasMeasurements; detailsCardMeasurement: DetailsCardMeasurement | null; @@ -92,6 +93,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; @@ -134,6 +136,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 748d8abb5d1ac..ce8de331b02fb 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"] : []), @@ -643,6 +646,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 6a9ce0557ca5e..fc16bcfd2656b 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 4c194f473c7b0..efecc2aef5a25 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -303,6 +303,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 2844c8181324a..1a0cb67384e1c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -405,6 +405,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({}))), @@ -2003,6 +2004,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),