Computer panel: reserve chat space (--computer-width) so the chat condenses, not hides

The absolute panel overlaid the full-width centered chat column, hiding the
messages/composer behind it (WorkClaw avoids this by reserving the panel's
width as padding on the chat). Introduced a shared --computer-width var,
set once on the ChatWorkspace row per ?device= (phone 448px, tablet 67%,
full 100%; 0 when closed). Both the panel <aside> width and a new chat-
column wrapper's md:pr read it, so they never drift: opening the panel
squeezes the centered max-w-3xl column into the left space (re-centered)
while the panel overlays the reserved right area. Animated with
transition-[padding]/[width] + the signature easing for the 'condense'
effect. The header is outside the padded wrapper, so its cluster shift is
unaffected. Verified at 1440: composer right clears the panel left edge in
both tablet (561<593) and phone (920<992) — nothing hidden.

86 unit + 31 functional E2E green.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-11 12:17:52 -05:00
co-authored by Claude Fable 5
parent 914ffae8f9
commit 21da4dca19
2 changed files with 40 additions and 28 deletions
+27 -7
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState, type CSSProperties } from "react";
import { useQueryStates } from "nuqs"; import { useQueryStates } from "nuqs";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
@@ -9,7 +9,17 @@ import type { Session } from "@/lib/api/sessions";
import { useChat } from "@/lib/gateway/use-chat"; import { useChat } from "@/lib/gateway/use-chat";
import type { UiMessage } from "@/lib/gateway/transcript"; import type { UiMessage } from "@/lib/gateway/transcript";
import { encodeSessionKeyParam } from "@/lib/url/session-key"; import { encodeSessionKeyParam } from "@/lib/url/session-key";
import { panelParsers } from "@/lib/url/panel-params"; import { panelParsers, type DeviceSize } from "@/lib/url/panel-params";
/* One source of truth for the Computer panel's width (measured): both the
panel <aside> (width) and the chat column (padding-right) read this var,
so the chat condenses into the left space instead of hiding behind the
panel — WorkClaw's reserve-space model. Collapses to 0 when closed. */
const COMPUTER_WIDTH: Record<DeviceSize, string> = {
phone: "448px",
tablet: "67%",
full: "100%",
};
import { SlidePanel } from "@/components/ui/SlidePanel"; import { SlidePanel } from "@/components/ui/SlidePanel";
import { SessionsColumn } from "@/components/sessions/SessionsColumn"; import { SessionsColumn } from "@/components/sessions/SessionsColumn";
import { DevicePanel } from "@/components/computer/DevicePanel"; import { DevicePanel } from "@/components/computer/DevicePanel";
@@ -41,9 +51,11 @@ export function ChatWorkspace({
); );
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const empty = state.messages.length === 0; const empty = state.messages.length === 0;
const [{ sessions: showSessions }] = useQueryStates(panelParsers, { const [{ sessions: showSessions, app, device }] = useQueryStates(
shallow: true, panelParsers,
}); { shallow: true },
);
const computerWidth = app !== null ? COMPUTER_WIDTH[device] : "0px";
function openSession(session: Session) { function openSession(session: Session) {
router.push( router.push(
@@ -66,7 +78,10 @@ export function ChatWorkspace({
} }
return ( return (
<div className="relative flex h-dvh"> <div
className="relative flex h-dvh"
style={{ "--computer-width": computerWidth } as CSSProperties}
>
<SlidePanel open={showSessions} width={208} label="Sessions"> <SlidePanel open={showSessions} width={208} label="Sessions">
<SessionsColumn <SessionsColumn
sessions={sessions} sessions={sessions}
@@ -77,7 +92,11 @@ export function ChatWorkspace({
</SlidePanel> </SlidePanel>
<section className="flex min-w-0 flex-1 flex-col"> <section className="flex min-w-0 flex-1 flex-col">
<ChatHeader agent={agent} onNewSession={newSession} /> <ChatHeader agent={agent} onNewSession={newSession} />
<div className="mx-auto flex w-full max-w-3xl min-w-0 flex-1 flex-col overflow-hidden px-4"> {/* Reserve the panel's width as right-padding so the centered chat
column re-centers in the left space and never hides behind the
absolute panel (animates in sync with the panel). */}
<div className="flex min-h-0 flex-1 flex-col transition-[padding] duration-(--duration-normal) ease-app md:pr-[var(--computer-width)]">
<div className="mx-auto flex w-full max-w-3xl min-w-0 flex-1 flex-col overflow-hidden px-4">
{empty ? ( {empty ? (
// Empty state: the composer is part of the centered welcome // Empty state: the composer is part of the centered welcome
// stack (avatar → heading → subtitle → composer → chips). // stack (avatar → heading → subtitle → composer → chips).
@@ -116,6 +135,7 @@ export function ChatWorkspace({
/> />
</> </>
)} )}
</div>
</div> </div>
</section> </section>
<DevicePanel agent={agent} /> <DevicePanel agent={agent} />
@@ -10,31 +10,25 @@ import {
} from "react"; } from "react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { panelParsers, type AppId, type DeviceSize } from "@/lib/url/panel-params"; import { panelParsers, type AppId } from "@/lib/url/panel-params";
import { clawThemeStyle, WallpaperSurface } from "./ClawTheme"; import { clawThemeStyle, WallpaperSurface } from "./ClawTheme";
import { HomeScreen } from "./HomeScreen"; import { HomeScreen } from "./HomeScreen";
import { AppRouter, appTitle } from "./AppRouter"; import { AppRouter, appTitle } from "./AppRouter";
/* Device-state sizing + positioning model (measured at 1440). The panel is /* The panel is ALWAYS an absolute, right-anchored overlay (never an in-flow
ALWAYS an absolute, right-anchored overlay (never an in-flow sibling) so sibling). Its width and the chat column's reserved padding both read the
the chat keeps its full width and never reflows — opening the panel just shared --computer-width var (set by ChatWorkspace per ?device=: phone
covers the right N% of the chat. Widths are proportional to the post-rail 448px, tablet 67%, full 100%; 0 when closed), so they never drift and the
area: phone 448px, tablet 67% (≈ the measured 847px), full 100% (and in chat condenses into the left space rather than hiding behind the panel.
full the rail collapses to 80px in parallel, giving the panel the room). The card stays w-full inside the px-6 wrapper → 24px inset + 32px radius. */
The card stays w-full inside the px-6 wrapper → 24px inset + 32px radius const OVERLAY =
throughout. */ "md:absolute md:top-0 md:right-0 md:z-30 md:h-full md:w-[var(--computer-width)]";
const OVERLAY = "md:absolute md:top-0 md:right-0 md:z-30 md:h-full";
const WIDTH: Record<DeviceSize, string> = {
phone: "md:w-[448px]",
tablet: "md:w-[67%]",
full: "md:w-full",
};
/** The right-hand "Computer" slide-out (§7): a per-agent themed screen card /** The right-hand "Computer" slide-out (§7): a per-agent themed screen card
* hosting every sub-app behind ?app=, sized by ?device=. The size toggles * hosting every sub-app behind ?app=. The size toggles and close live in
* and close live in the chat header; this owns the card + its width. */ * the chat header; this owns the card. */
export function DevicePanel({ agent }: { agent: Agent }) { export function DevicePanel({ agent }: { agent: Agent }) {
const [{ app, device }, setParams] = useQueryStates(panelParsers, { const [{ app }, setParams] = useQueryStates(panelParsers, {
shallow: true, shallow: true,
}); });
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null);
@@ -79,9 +73,7 @@ export function DevicePanel({ agent }: { agent: Agent }) {
aria-label="Computer" aria-label="Computer"
aria-hidden={!open} aria-hidden={!open}
onTransitionEnd={handleTransitionEnd} onTransitionEnd={handleTransitionEnd}
className={`pointer-events-none overflow-hidden transition-[width] duration-(--duration-normal) ease-app max-md:fixed max-md:inset-0 max-md:z-40 max-md:h-dvh ${OVERLAY} ${ className={`pointer-events-none overflow-hidden transition-[width] duration-(--duration-normal) ease-app max-md:fixed max-md:inset-0 max-md:z-40 max-md:h-dvh ${OVERLAY}`}
open ? WIDTH[device] : "md:w-0"
}`}
> >
<div <div
data-testid="device-panel-theme" data-testid="device-panel-theme"