Skip to content

Commit 2732d66

Browse files
committed
feat(web): harden responsive workspace layout
1 parent 85a9c88 commit 2732d66

7 files changed

Lines changed: 377 additions & 28 deletions

File tree

apps/web/src/components/ChatView.browser.tsx

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import { useTerminalStateStore } from "../terminalStateStore";
6767
import { resetRetainedThreadDetailSubscriptionsForTests } from "../threadDetailSubscriptionRetention";
6868
import { useWorkspacePathsStore } from "../workspacePathsStore";
6969
import { resetWsNativeApiForTest } from "../wsNativeApi";
70+
import { useRightDockStore } from "../rightDockStore";
7071
// Pre-transform the compiler-heavy component outside the first case's timeout.
7172
// The router's auto-split route otherwise requests this module on first mount.
7273
import "./ChatView";
@@ -2151,6 +2152,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
21512152
useTerminalStateStore.setState({
21522153
terminalStateByThreadId: {},
21532154
});
2155+
useRightDockStore.setState({ dockStateByThreadId: {} });
21542156
useSplitViewStore.setState({
21552157
splitViewsById: {},
21562158
splitViewIdBySourceThreadId: {},
@@ -8657,4 +8659,102 @@ describe("ChatView timeline estimator parity (full app)", () => {
86578659
await mounted.cleanup();
86588660
}
86598661
});
8662+
8663+
it("[geometry:linux] coordinates sidebar, dock, Environment, and composer bounds", async () => {
8664+
useRightDockStore.getState().openPane(THREAD_ID, {
8665+
kind: "browser",
8666+
paneId: "layout-browser",
8667+
});
8668+
const mounted = await mountChatView({
8669+
viewport: { ...DEFAULT_VIEWPORT, width: 1_440, height: 900 },
8670+
snapshot: createSnapshotForTargetUser({
8671+
targetMessageId: "msg-user-workspace-layout" as MessageId,
8672+
targetText: "Keep the workspace controls reachable.",
8673+
}),
8674+
});
8675+
8676+
try {
8677+
const environmentToggle = await waitForElement(
8678+
() =>
8679+
document.querySelector<HTMLButtonElement>(
8680+
'button[aria-label="Toggle environment panel"]',
8681+
),
8682+
"Environment toggle did not mount.",
8683+
);
8684+
await userEvent.click(environmentToggle);
8685+
const environment = await waitForElement(
8686+
() =>
8687+
document.querySelector<HTMLElement>(
8688+
'[data-environment-panel-variant]:not([aria-hidden="true"])',
8689+
),
8690+
"Environment panel did not open.",
8691+
);
8692+
const composer = await waitForElement(
8693+
() => document.querySelector<HTMLElement>('[data-chat-composer-form="true"]'),
8694+
"Composer did not mount.",
8695+
);
8696+
const dock = await waitForElement(
8697+
() => document.querySelector<HTMLElement>("[data-right-dock-content]"),
8698+
"Right dock did not mount.",
8699+
);
8700+
const chat = await waitForElement(
8701+
() => document.querySelector<HTMLElement>('[data-chat-pane-drop-overlay="true"]'),
8702+
"Chat surface did not mount.",
8703+
);
8704+
8705+
await vi.waitFor(
8706+
() => {
8707+
expect(environment.getBoundingClientRect().bottom).toBeLessThanOrEqual(
8708+
composer.getBoundingClientRect().top + 1,
8709+
);
8710+
expect(chat.getBoundingClientRect().width).toBeGreaterThanOrEqual(36 * 16);
8711+
expect(dock.getBoundingClientRect().width).toBeLessThanOrEqual(608);
8712+
},
8713+
{ timeout: 8_000, interval: 16 },
8714+
);
8715+
8716+
const forkTools = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find(
8717+
(button) => button.textContent?.includes("Fork Tools"),
8718+
);
8719+
const forkLore = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find(
8720+
(button) => button.textContent?.includes("Fork Lore"),
8721+
);
8722+
expect(forkTools?.getAttribute("aria-expanded")).toBe("false");
8723+
expect(forkLore?.getAttribute("aria-expanded")).toBe("false");
8724+
if (!forkTools) throw new Error("Fork Tools disclosure did not mount.");
8725+
await userEvent.click(forkTools);
8726+
expect(forkTools.getAttribute("aria-expanded")).toBe("true");
8727+
8728+
await mounted.setViewport({ ...DEFAULT_VIEWPORT, width: 1_024, height: 768 });
8729+
await vi.waitFor(
8730+
() => {
8731+
expect(
8732+
document.querySelector('[data-workspace-sidebar-suppressed="true"]'),
8733+
).toBeTruthy();
8734+
expect(chat.getBoundingClientRect().width).toBeGreaterThanOrEqual(36 * 16);
8735+
expect(dock.getBoundingClientRect().width).toBeLessThanOrEqual(448);
8736+
expect(environment.getBoundingClientRect().bottom).toBeLessThanOrEqual(
8737+
composer.getBoundingClientRect().top + 1,
8738+
);
8739+
},
8740+
{ timeout: 8_000, interval: 16 },
8741+
);
8742+
8743+
await mounted.setViewport({ ...DEFAULT_VIEWPORT, width: 1_440, height: 900 });
8744+
await vi.waitFor(
8745+
() => {
8746+
expect(
8747+
document.querySelector('[data-workspace-sidebar-suppressed="true"]'),
8748+
).toBeNull();
8749+
const sidebarGap = document.querySelector<HTMLElement>(
8750+
"[data-sidebar-side='left'] [data-slot='sidebar-gap']",
8751+
);
8752+
expect(sidebarGap?.getBoundingClientRect().width ?? 0).toBeGreaterThan(0);
8753+
},
8754+
{ timeout: 8_000, interval: 16 },
8755+
);
8756+
} finally {
8757+
await mounted.cleanup();
8758+
}
8759+
});
86608760
});

apps/web/src/components/ChatView.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12515,6 +12515,7 @@ export default function ChatView({
1251512515
{...environmentPanelProps}
1251612516
open={environmentPanelVisible}
1251712517
variant={environmentOverlayVariant}
12518+
bottomInsetPx={composerOverlayHeightPx + 12}
1251812519
/>
1251912520
) : null}
1252012521
</div>

apps/web/src/components/chat/RightDock.tsx

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
resolveRightDockPaneLabel,
5050
} from "./rightDockPaneMeta";
5151
import { useDesktopTopBarWindowControlsGutterClassName } from "~/hooks/useDesktopTopBarGutter";
52+
import { clampRightDockWidth, resolveRightDockMaxWidth } from "~/workspaceLayout";
5253

5354
// Shared sizing defaults for dock hosts: the resize floor for a single readable pane and the
5455
// "half the shell, but never cramped" opening width. The thread route tunes its own values
@@ -197,26 +198,60 @@ export function RightDock(props: RightDockProps) {
197198
// pin the dock width to exactly half of it. Mid-session drags still resize
198199
// freely; the next open re-centers the split.
199200
const contentRef = useRef<HTMLDivElement | null>(null);
201+
const preferredWidthRef = useRef<number | null>(null);
202+
const previousOpenRef = useRef(false);
203+
const previousActivePaneKindRef = useRef<RightDockPaneKind | null>(null);
204+
const [hostWidthPx, setHostWidthPx] = useState(0);
200205
const minWidth = props.minWidth;
201206
const activePaneKind = activePane?.kind ?? null;
202-
useEffect(() => {
203-
if (!props.state.open) {
204-
return;
205-
}
207+
useLayoutEffect(() => {
206208
const wrapper = contentRef.current?.closest<HTMLElement>("[data-slot='sidebar-wrapper']");
207209
const shell = wrapper?.parentElement;
208210
if (!wrapper || !shell) {
209211
return;
210212
}
213+
const measure = () => setHostWidthPx(Math.round(shell.getBoundingClientRect().width));
214+
measure();
215+
if (typeof ResizeObserver === "undefined") {
216+
window.addEventListener("resize", measure);
217+
return () => window.removeEventListener("resize", measure);
218+
}
219+
const observer = new ResizeObserver(measure);
220+
observer.observe(shell);
221+
return () => observer.disconnect();
222+
}, []);
223+
224+
useLayoutEffect(() => {
225+
if (!props.state.open) {
226+
previousOpenRef.current = false;
227+
previousActivePaneKindRef.current = activePaneKind;
228+
return;
229+
}
230+
const wrapper = contentRef.current?.closest<HTMLElement>("[data-slot='sidebar-wrapper']");
231+
const shell = wrapper?.parentElement;
232+
if (!wrapper || !shell) return;
233+
const measuredHostWidthPx = hostWidthPx || Math.round(shell.getBoundingClientRect().width);
234+
if (measuredHostWidthPx <= 0) return;
211235
// A phone-shaped pane has a natural width: half the shell leaves the device
212236
// stranded in empty space, so kinds that render a fixed-aspect object open
213237
// at their own comfortable size instead of the even split.
214-
const preferredWidth = activePaneKind ? RIGHT_DOCK_PREFERRED_WIDTH[activePaneKind] : undefined;
215-
const openWidth = preferredWidth ?? Math.round(shell.getBoundingClientRect().width / 2);
216-
if (openWidth > 0) {
217-
wrapper.style.setProperty("--sidebar-width", `${Math.max(minWidth, openWidth)}px`);
238+
const isOpening = !previousOpenRef.current;
239+
const paneKindChanged = previousActivePaneKindRef.current !== activePaneKind;
240+
if (isOpening || paneKindChanged || preferredWidthRef.current === null) {
241+
preferredWidthRef.current =
242+
(activePaneKind ? RIGHT_DOCK_PREFERRED_WIDTH[activePaneKind] : undefined) ??
243+
Math.round(measuredHostWidthPx / 2);
218244
}
219-
}, [props.state.open, minWidth, activePaneKind]);
245+
const nextWidthPx = clampRightDockWidth({
246+
requestedWidthPx: preferredWidthRef.current,
247+
hostWidthPx: measuredHostWidthPx,
248+
minWidthPx: minWidth,
249+
});
250+
wrapper.style.setProperty("--sidebar-width", `${nextWidthPx}px`);
251+
previousOpenRef.current = true;
252+
previousActivePaneKindRef.current = activePaneKind;
253+
}, [props.state.open, minWidth, activePaneKind, hostWidthPx]);
254+
const maxWidth = resolveRightDockMaxWidth(hostWidthPx);
220255
const renderedPanes = props.state.panes.filter(
221256
(pane) => pane.id === activePane?.id || keepMountedPaneIds.has(pane.id),
222257
);
@@ -268,6 +303,10 @@ export function RightDock(props: RightDockProps) {
268303
transparentSurface
269304
resizable={{
270305
minWidth: props.minWidth,
306+
...(maxWidth === null ? {} : { maxWidth }),
307+
onResize: (width) => {
308+
preferredWidthRef.current = width;
309+
},
271310
shouldAcceptWidth: props.shouldAcceptWidth,
272311
}}
273312
>

apps/web/src/components/chat/environment/EnvironmentPanel.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import type {
2121
ThreadMarkerId,
2222
} from "@forkara/contracts";
2323
import { useNavigate } from "@tanstack/react-router";
24-
import { type ReactNode, useEffect } from "react";
24+
import { type CSSProperties, type ReactNode, useEffect } from "react";
2525

2626
import { useAppSettings } from "~/appSettings";
2727
import { SETTINGS_TARGETS } from "~/settingsNavigation";
@@ -125,6 +125,8 @@ export interface EnvironmentPanelProps {
125125
* only, no content inset.
126126
*/
127127
variant: "docked" | "floating";
128+
/** Measured composer footprint. The panel's bottom edge stays above this live inset. */
129+
bottomInsetPx?: number;
128130
gitCwd: string | null;
129131
openInTarget: string | null;
130132
githubRepository?: {
@@ -250,6 +252,7 @@ function EnvironmentRecapSection({
250252
export function EnvironmentPanel({
251253
open,
252254
variant,
255+
bottomInsetPx = 0,
253256
gitCwd,
254257
openInTarget,
255258
githubRepository: githubRepositoryProp,
@@ -597,7 +600,9 @@ export function EnvironmentPanel({
597600
<div
598601
className={ENVIRONMENT_PANEL_OVERLAY_WRAPPER_CLASS_NAME}
599602
data-environment-panel-variant={variant}
603+
data-environment-panel-bottom-inset={Math.max(0, Math.round(bottomInsetPx))}
600604
aria-hidden={!open}
605+
style={{ bottom: Math.max(0, Math.round(bottomInsetPx)) } as CSSProperties}
601606
>
602607
<div
603608
className={cn(

0 commit comments

Comments
 (0)