Left rail restructure + Computer panel as a right-anchored overlay

PART 2 — panel positioning (the 'pushes the chat left' root cause):
DevicePanel was an in-flow flex sibling, so opening it consumed width and
shoved the chat. Now phone/tablet anchor it as an ABSOLUTE right-edge
overlay (md:absolute right-0 top-0 h-full z-30, w-[448px]/w-[550px]) so
the chat keeps full width and does NOT reflow; only 'full' stays in-flow
(grow-[4]) and lets the chat shrink. The chat row is now relative; the
aside is pointer-events-none with the screen card pointer-events-auto so
the header toggles/close stay clickable under the overlay's transparent
top padding. Verified live: composer.left is identical (472px) panel
open vs closed in tablet — the chat no longer shifts.

PART 1 — left rail:
- Logo in its own 80px header row as a 48px black rounded-2xl brand tile
  with a coral PawPrint mark (our placeholder logo) + sr-only 'clawmates'.
- Agent tiles bumped to 58px (Avatar 'rail' size + rounded-[20px]
  squircle), centered, gap-1, 2px coral active ring (rounded-[22px]).
- Hover-revealed ⋯ menu per agent row (new AgentRowMenu): a 176px #1A1A1A
  popover with Pin + Settings rows (coral 15px icons, 14px labels), closes
  on outside-click/Escape. Pin pins the claw to the top of the rail
  (persisted in localStorage, client-side); Settings deep-links to that
  claw's Computer → Settings (the claw redirect now forwards ?app=).

86 unit + 31 functional E2E green.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-11 05:26:09 -05:00
co-authored by Claude Fable 5
parent 9456edf198
commit 96e0134fc6
10 changed files with 280 additions and 69 deletions
@@ -4,16 +4,22 @@ import { createSession, fetchSessions } from "@/lib/api/sessions";
import { encodeSessionKeyParam } from "@/lib/url/session-key"; import { encodeSessionKeyParam } from "@/lib/url/session-key";
// Clicking a claw in the rail lands here: resume the most recent session, // Clicking a claw in the rail lands here: resume the most recent session,
// or open a fresh one (sessions are resumable, §6). // or open a fresh one (sessions are resumable, §6). An optional ?app= is
// forwarded so the rail's per-claw "Settings" deep-link opens the Computer
// Settings app on landing.
export default async function ClawHome({ export default async function ClawHome({
params, params,
searchParams,
}: { }: {
params: Promise<{ clawId: string }>; params: Promise<{ clawId: string }>;
searchParams: Promise<{ app?: string }>;
}) { }) {
const { clawId } = await params; const { clawId } = await params;
const { app } = await searchParams;
const sessions = await fetchSessions(clawId); const sessions = await fetchSessions(clawId);
const target = sessions[0] ?? (await createSession(clawId)); const target = sessions[0] ?? (await createSession(clawId));
const suffix = app ? `?app=${encodeURIComponent(app)}` : "";
redirect( redirect(
`/claws/${clawId}/chat/${encodeSessionKeyParam(target.sessionKey)}`, `/claws/${clawId}/chat/${encodeSessionKeyParam(target.sessionKey)}${suffix}`,
); );
} }
@@ -66,7 +66,7 @@ export function ChatWorkspace({
} }
return ( return (
<div className="flex h-dvh"> <div className="relative flex h-dvh">
<SlidePanel open={showSessions} width={208} label="Sessions"> <SlidePanel open={showSessions} width={208} label="Sessions">
<SessionsColumn <SessionsColumn
sessions={sessions} sessions={sessions}
@@ -15,15 +15,20 @@ 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 (measured): phone = 448px aside / 400 card, tablet = /* Device-state sizing + positioning model (measured). Phone/tablet anchor
550 / 502, full = fluid (the panel drops its fixed width and fills the the panel as an ABSOLUTE right-edge overlay so the chat keeps its full
row beside the chat). Driven by flex so "full" is responsive; the grow width and doesn't reflow when the panel opens (448/550px aside → 400/502
animates with the signature easing. The card is w-full inside a px-6 card). Only "full" is an in-flow flex sibling that lets the chat shrink
wrapper, so card width = aside − 48. */ (basis-0 grow-[4] → fluid, fills the row). The card is w-full inside a
const SIZING: Record<DeviceSize, string> = { px-6 wrapper, so card width = aside − 48. */
phone: "basis-[448px] grow-0", const OVERLAY = "md:absolute md:top-0 md:right-0 md:z-30 md:h-full";
tablet: "basis-[550px] grow-0", const SIZING: Record<DeviceSize, { open: string; closed: string }> = {
full: "basis-0 grow-[4]", phone: { open: `${OVERLAY} md:w-[448px]`, closed: `${OVERLAY} md:w-0` },
tablet: { open: `${OVERLAY} md:w-[550px]`, closed: `${OVERLAY} md:w-0` },
full: {
open: "md:relative md:basis-0 md:grow-[4] md:shrink-0",
closed: "md:relative md:basis-0 md:grow-0",
},
}; };
/** 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
@@ -49,7 +54,9 @@ export function DevicePanel({ agent }: { agent: Agent }) {
if ( if (
!open && !open &&
event.target === event.currentTarget && event.target === event.currentTarget &&
(event.propertyName === "flex-basis" || event.propertyName === "flex-grow") (event.propertyName === "width" ||
event.propertyName === "flex-basis" ||
event.propertyName === "flex-grow")
) { ) {
setMounted(false); setMounted(false);
} }
@@ -75,8 +82,8 @@ export function DevicePanel({ agent }: { agent: Agent }) {
aria-label="Computer" aria-label="Computer"
aria-hidden={!open} aria-hidden={!open}
onTransitionEnd={handleTransitionEnd} onTransitionEnd={handleTransitionEnd}
className={`shrink-0 overflow-hidden transition-[flex-basis,flex-grow] duration-(--duration-normal) ease-app max-md:fixed max-md:inset-0 max-md:z-40 max-md:h-dvh max-md:basis-auto max-md:grow ${ className={`pointer-events-none overflow-hidden transition-[width,flex-basis,flex-grow] duration-(--duration-normal) ease-app max-md:fixed max-md:inset-0 max-md:z-40 max-md:h-dvh ${
open ? SIZING[device] : "basis-0 grow-0" open ? SIZING[device].open : SIZING[device].closed
}`} }`}
> >
<div <div
@@ -86,7 +93,7 @@ export function DevicePanel({ agent }: { agent: Agent }) {
> >
<div <div
ref={cardRef} ref={cardRef}
className="relative flex h-full w-full flex-col overflow-hidden rounded-[32px] shadow-screen max-md:rounded-none max-md:shadow-none" className="pointer-events-auto relative flex h-full w-full flex-col overflow-hidden rounded-[32px] shadow-screen max-md:rounded-none max-md:shadow-none"
> >
{mounted && ( {mounted && (
<WallpaperSurface> <WallpaperSurface>
@@ -1,25 +1,34 @@
"use client";
import Link from "next/link"; import Link from "next/link";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
import { AgentRowMenu } from "./AgentRowMenu";
/** One claw in the rail stack (measured): 48px squircle avatar with a 2px /** One claw in the rail stack (measured): 58px squircle avatar with a 2px
* coral ring when its chat is open, name beneath, online dot. */ * coral ring when its chat is open, name beneath, online dot, and a
* hover-revealed ⋯ menu (Pin / Settings). */
export function AgentRosterItem({ export function AgentRosterItem({
agent, agent,
active = false, active = false,
pinned = false,
onTogglePin = () => {},
}: { }: {
agent: Agent; agent: Agent;
active?: boolean; active?: boolean;
pinned?: boolean;
onTogglePin?: (id: string) => void;
}) { }) {
return ( return (
<div className="group relative flex flex-col items-center">
<Link <Link
href={`/claws/${agent.id}`} href={`/claws/${agent.id}`}
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
className="group flex w-full flex-col items-center gap-1 py-1.5" className="flex w-full flex-col items-center gap-1"
> >
<span <span
className={`rounded-[19px] p-0.5 transition-colors duration-(--duration-normal) ease-app ${ className={`rounded-[22px] p-0.5 transition-colors duration-(--duration-normal) ease-app ${
active ? "ring-2 ring-coral" : "" active ? "ring-2 ring-coral" : ""
}`} }`}
> >
@@ -49,5 +58,12 @@ export function AgentRosterItem({
{agent.name} {agent.name}
</span> </span>
</Link> </Link>
<AgentRowMenu
agentId={agent.id}
agentName={agent.name}
pinned={pinned}
onTogglePin={onTogglePin}
/>
</div>
); );
} }
@@ -0,0 +1,39 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { AgentRowMenu } from "./AgentRowMenu";
describe("AgentRowMenu", () => {
it("opens on the ⋯ trigger and offers Pin + Settings", async () => {
const user = userEvent.setup();
render(
<AgentRowMenu
agentId="claw-1"
agentName="Scout"
pinned={false}
onTogglePin={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: "Options for Scout" }));
expect(screen.getByRole("menuitem", { name: "Pin" })).toBeInTheDocument();
const settings = screen.getByRole("menuitem", { name: "Settings" });
expect(settings).toHaveAttribute("href", "/claws/claw-1?app=settings");
});
it("toggles the pin and shows Unpin when already pinned", async () => {
const user = userEvent.setup();
const onTogglePin = vi.fn();
render(
<AgentRowMenu
agentId="claw-1"
agentName="Scout"
pinned
onTogglePin={onTogglePin}
/>,
);
await user.click(screen.getByRole("button", { name: "Options for Scout" }));
await user.click(screen.getByRole("menuitem", { name: "Unpin" }));
expect(onTogglePin).toHaveBeenCalledWith("claw-1");
});
});
@@ -0,0 +1,94 @@
"use client";
import Link from "next/link";
import { MoreHorizontal, Pin, PinOff, Settings } from "lucide-react";
import { useEffect, useRef, useState } from "react";
/** The hover-revealed ⋯ menu on each rail agent row (measured): a 176px
* #1A1A1A popover with Pin + Settings rows (coral 15px icons). Pin toggles
* the claw's pinned state; Settings deep-links into its Computer Settings. */
export function AgentRowMenu({
agentId,
agentName,
pinned,
onTogglePin,
}: {
agentId: string;
agentName: string;
pinned: boolean;
onTogglePin: (id: string) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function onDown(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpen(false);
}
}
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const rowClass =
"flex h-10 w-full items-center gap-3 rounded-xl px-3 text-sm text-neutral-200 transition-colors duration-(--duration-normal) ease-app hover:bg-hover-bg";
return (
<div ref={ref} className="absolute top-0 right-0">
<button
type="button"
aria-label={`Options for ${agentName}`}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className={`inline-flex size-5 items-center justify-center rounded-md text-muted-foreground transition-opacity duration-(--duration-normal) ease-app hover:text-foreground ${
open ? "opacity-100" : "opacity-0 group-hover:opacity-100"
}`}
>
<MoreHorizontal aria-hidden size={14} />
</button>
{open && (
<div
role="menu"
aria-label={`${agentName} options`}
className="absolute top-6 right-0 z-50 w-44 rounded-2xl bg-[#1a1a1a] p-1.5 shadow-popover ring-1 ring-white/[0.06]"
>
<button
type="button"
role="menuitem"
onClick={() => {
onTogglePin(agentId);
setOpen(false);
}}
className={rowClass}
>
{pinned ? (
<PinOff aria-hidden size={15} className="text-coral" />
) : (
<Pin aria-hidden size={15} className="text-coral" />
)}
{pinned ? "Unpin" : "Pin"}
</button>
<Link
role="menuitem"
href={`/claws/${agentId}?app=settings`}
onClick={() => setOpen(false)}
className={rowClass}
>
<Settings aria-hidden size={15} className="text-coral" />
Settings
</Link>
</div>
)}
</div>
);
}
+14 -9
View File
@@ -2,7 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Menu, Plus, X } from "lucide-react"; import { Menu, PawPrint, Plus, X } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import type { Agent, User } from "@/lib/api/schemas"; import type { Agent, User } from "@/lib/api/schemas";
@@ -49,24 +49,29 @@ export function LeftRail({ user, roster, creditsBalance }: LeftRailProps) {
/> />
)} )}
<aside <aside
className={`z-50 flex h-dvh w-rail shrink-0 flex-col px-1 py-4 transition-transform duration-(--duration-normal) ease-app max-md:fixed max-md:bg-background max-md:shadow-edge ${ className={`z-50 flex h-dvh w-rail shrink-0 flex-col px-1 transition-transform duration-(--duration-normal) ease-app max-md:fixed max-md:bg-background max-md:shadow-edge ${
open ? "max-md:translate-x-0" : "max-md:-translate-x-full" open ? "max-md:translate-x-0" : "max-md:-translate-x-full"
}`} }`}
> >
<div className="flex items-center justify-between px-3 pb-4"> {/* The logo lives in its own 80px header row (measured): a 48px
<p className="font-mono text-sm font-semibold tracking-tight text-foreground"> black squircle brand tile with our coral mark. */}
clawmates <header className="flex h-20 items-center pl-4">
</p> <Link href="/" aria-label="clawmates" className="inline-flex">
<span className="flex size-12 items-center justify-center rounded-2xl bg-black">
<PawPrint aria-hidden size={26} className="text-coral" />
</span>
<span className="sr-only">clawmates</span>
</Link>
<button <button
type="button" type="button"
aria-label="Close navigation" aria-label="Close navigation"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="hidden text-muted-foreground max-md:inline-flex" className="ml-auto hidden pr-3 text-muted-foreground max-md:inline-flex"
> >
<X aria-hidden size={16} /> <X aria-hidden size={16} />
</button> </button>
</div> </header>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto py-4">
{roster.length === 0 ? ( {roster.length === 0 ? (
<p className="px-3 text-xs text-muted-foreground">No claws yet</p> <p className="px-3 text-xs text-muted-foreground">No claws yet</p>
) : ( ) : (
+48 -5
View File
@@ -1,21 +1,64 @@
"use client"; "use client";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { AgentRosterItem } from "./AgentRosterItem"; import { AgentRosterItem } from "./AgentRosterItem";
/** Client wrapper computing which claw's chat is open from the pathname const PIN_KEY = "clawmates:pinned";
* (mirrors ShellNav) so the roster item itself stays presentational. */
/** Client wrapper: computes the active claw from the pathname (mirrors
* ShellNav) and owns the locally-persisted pin order so the roster item
* itself stays presentational. */
export function RosterList({ roster }: { roster: Agent[] }) { export function RosterList({ roster }: { roster: Agent[] }) {
const pathname = usePathname(); const pathname = usePathname();
const [pinned, setPinned] = useState<string[]>([]);
// Pins are a per-browser preference (no backend) — hydrate after mount to
// avoid a server/client mismatch.
useEffect(() => {
try {
const raw = localStorage.getItem(PIN_KEY);
// Hydrate the per-browser pin order after mount (server renders the
// default order; this reorders client-side only).
// eslint-disable-next-line react-hooks/set-state-in-effect
if (raw) setPinned(JSON.parse(raw) as string[]);
} catch {
// ignore malformed storage
}
}, []);
function togglePin(id: string) {
setPinned((prev) => {
const next = prev.includes(id)
? prev.filter((x) => x !== id)
: [...prev, id];
try {
localStorage.setItem(PIN_KEY, JSON.stringify(next));
} catch {
// ignore storage write failures
}
return next;
});
}
const pinnedSet = new Set(pinned);
const ordered = [...roster].sort((a, b) => {
const ap = pinnedSet.has(a.id) ? pinned.indexOf(a.id) : Infinity;
const bp = pinnedSet.has(b.id) ? pinned.indexOf(b.id) : Infinity;
return ap - bp; // pinned first (in pin order); the rest keep server order
});
return ( return (
<ul aria-label="Your claws" className="flex flex-col"> <ul aria-label="Your claws" className="flex flex-col items-center gap-1">
{roster.map((agent) => ( {ordered.map((agent) => (
<li key={agent.id}> <li key={agent.id} className="w-full">
<AgentRosterItem <AgentRosterItem
agent={agent} agent={agent}
active={pathname.startsWith(`/claws/${agent.id}`)} active={pathname.startsWith(`/claws/${agent.id}`)}
pinned={pinnedSet.has(agent.id)}
onTogglePin={togglePin}
/> />
</li> </li>
))} ))}
+3 -3
View File
@@ -9,14 +9,14 @@ describe("Avatar", () => {
expect(container.textContent).toBe("SL"); expect(container.textContent).toBe("SL");
}); });
it("squircle shape uses the 17px squircle radius instead of a circle", () => { it("squircle shape uses a squircle radius instead of a circle", () => {
const { container } = render( const { container } = render(
<Avatar name="Scout" shape="squircle" size="rail" />, <Avatar name="Scout" shape="squircle" size="rail" />,
); );
const span = container.querySelector("span[aria-hidden]")!; const span = container.querySelector("span[aria-hidden]")!;
expect(span.className).toContain("rounded-[17px]"); expect(span.className).toContain("rounded-[20px]");
expect(span.className).not.toContain("rounded-full"); expect(span.className).not.toContain("rounded-full");
expect(span.className).toContain("size-12"); /* 48px rail size */ expect(span.className).toContain("size-[58px]"); /* 58px rail tile */
}); });
it("welcome size renders the 126px hero avatar", () => { it("welcome size renders the 126px hero avatar", () => {
+2 -1
View File
@@ -13,7 +13,7 @@ const SIZES = {
sm: "size-6 text-xxs", sm: "size-6 text-xxs",
md: "size-8 text-xs", md: "size-8 text-xs",
lg: "size-16 text-xl", lg: "size-16 text-xl",
rail: "size-12 text-base" /* 48px claw rail tile */, rail: "size-[58px] text-xl" /* 58px claw rail tile */,
chat: "size-[50px] text-base" /* assistant message avatar */, chat: "size-[50px] text-base" /* assistant message avatar */,
header: "size-[58px] text-2xl" /* chat-header identity */, header: "size-[58px] text-2xl" /* chat-header identity */,
welcome: "size-[126px] text-4xl" /* empty-state hero */, welcome: "size-[126px] text-4xl" /* empty-state hero */,
@@ -22,6 +22,7 @@ const SIZES = {
/* Squircle radius scales with the tile so big avatars read as rounded as /* Squircle radius scales with the tile so big avatars read as rounded as
the 48px rail tile (a flat 17px looks square at 126px). */ the 48px rail tile (a flat 17px looks square at 126px). */
const SQUIRCLE_RADIUS: Partial<Record<keyof typeof SIZES, string>> = { const SQUIRCLE_RADIUS: Partial<Record<keyof typeof SIZES, string>> = {
rail: "rounded-[20px]",
header: "rounded-[20px]", header: "rounded-[20px]",
welcome: "rounded-[40px]", welcome: "rounded-[40px]",
}; };