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
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1961,6 +1961,7 @@ export default function ChatView(props: ChatViewProps) {
threadRef: activeThreadRef,
preview: canvasPreview,
detailsCardInlineOpen: threadPanelInlineOpen,
centerChatBesideDetails: settings.centerChatBesideDetails,
}).detailsCardMode,
);
const threadPanelOpen = useRightPanelStore((state) =>
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/chat/ChatCanvas.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -24,6 +25,7 @@ export function ChatCanvas({
composerOverlayElement: HTMLElement | null;
detailsCardTopInset?: number;
}) {
const centerChatBesideDetails = useClientSettings((settings) => settings.centerChatBesideDetails);
const elementRef = useRef<HTMLDivElement | null>(null);
const widthProbeRef = useRef<HTMLDivElement | null>(null);
const [timelineElement, registerTimeline] = useState<HTMLElement | null>(null);
Expand Down Expand Up @@ -78,6 +80,7 @@ export function ChatCanvas({
threadRef,
preview,
detailsCardInlineOpen,
centerChatBesideDetails,
});
const context = {
container: model.container,
Expand Down
35 changes: 33 additions & 2 deletions apps/web/src/components/chat/canvasStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,22 +31,52 @@ 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,
detailsCardInlineOpen: true,
}).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" },
Expand All @@ -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", () => {
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/chat/canvasStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,15 @@ export function resolveChatCanvasModel({
threadRef,
preview,
detailsCardInlineOpen,
centerChatBesideDetails = false,
}: {
measurements: ChatCanvasMeasurements;
detailsCardMeasurement: DetailsCardMeasurement | null;
/** The thread whose details card sits on the canvas; other threads' measurements are stale. */
threadRef: ScopedThreadRef | null;
preview: ChatCanvasPreview | null;
detailsCardInlineOpen: boolean;
centerChatBesideDetails?: boolean;
}) {
const container = { width: measurements.width, height: measurements.height };
const { findBar } = measurements;
Expand Down Expand Up @@ -134,6 +136,7 @@ export function resolveChatCanvasModel({
maxChatWidth: measurements.maxChatWidth,
minChatWidth: measurements.minChatWidth,
composerHeight: measurements.composerHeight,
centerChatBesideDetails,
detailsCard,
findBar,
});
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/components/chat/chatCanvasLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/components/chat/chatCanvasLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function resolveChatCanvasLayout({
minChatWidth = 640,
composerHeight = 0,
detailsCard = null,
centerChatBesideDetails = false,
findBar = null,
}: {
container: PreviewMiniPlayerSize;
Expand All @@ -38,15 +39,17 @@ 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));
const laneRight = detailsCard
? 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,
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/components/settings/AppearanceSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,32 @@ export function AppearanceSettingsPanel() {
</div>
}
/>
<SettingsRow
{...searchableSetting("center-chat-beside-details")}
description="Center messages and the composer in the space beside an open thread details card."
resetAction={
settings.centerChatBesideDetails !==
DEFAULT_UNIFIED_SETTINGS.centerChatBesideDetails ? (
<SettingResetButton
label="chat centering"
onClick={() =>
updateSettings({
centerChatBesideDetails: DEFAULT_UNIFIED_SETTINGS.centerChatBesideDetails,
})
}
/>
) : null
}
control={
<Switch
checked={settings.centerChatBesideDetails}
onCheckedChange={(centerChatBesideDetails) =>
updateSettings({ centerChatBesideDetails })
}
aria-label="Center chat beside thread details"
/>
}
/>
<ThreadDetailsItemsRow />
<SettingsRow
{...searchableSetting("effort-display")}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,9 @@ export function useSettingsRestore(onRestored?: () => 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"]
: []),
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({}))),
Expand Down Expand Up @@ -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),
Expand Down
Loading