Unify panel sub-screen header (one morphing header) — P2

Eliminates the double-header on Settings-edit / Files-folder sub-screens
(they previously stacked the panel's app header + an inline text-xs back).
Now one sticky panel header morphs: app name + close-X at the top screen,
back-chevron + text-lg sub-title when a sub-screen registers itself.

- AppShell: SubHeaderContext + useSubHeader(active, title, onBack) hook;
  removed the dead SubViewHeader (nothing used it — my earlier restyle of it
  was moot; the apps had inline backs).
- DevicePanel: single header morphs on the registered sub-header; provides
  the context around AppRouter; resets on app change.
- SettingsApp (edit) + FilesApp (drive): register via useSubHeader, inline
  back buttons removed.

Verified by rendering the edit screen (single "‹ Edit profile" header).
typecheck/lint/86 unit/37 E2E+visual all green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-12 21:57:44 -05:00
co-authored by Claude Opus 4.8
parent 95df0f3a98
commit 65a0f1aab9
4 changed files with 78 additions and 62 deletions
@@ -1,8 +1,9 @@
"use client"; "use client";
import { X } from "lucide-react"; import { ChevronLeft, X } from "lucide-react";
import { useQueryStates } from "nuqs"; import { useQueryStates } from "nuqs";
import { import {
useEffect,
useRef, useRef,
useState, useState,
type CSSProperties, type CSSProperties,
@@ -14,6 +15,7 @@ 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";
import { SubHeaderContext, type SubHeader } from "./apps/AppShell";
/* The panel is ALWAYS an absolute, right-anchored overlay (never an in-flow /* The panel is ALWAYS an absolute, right-anchored overlay (never an in-flow
sibling). Its width and the chat column's reserved padding both read the sibling). Its width and the chat column's reserved padding both read the
@@ -34,9 +36,17 @@ export function DevicePanel({ agent }: { agent: Agent }) {
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null);
const [zoom, setZoom] = useState<CSSProperties>({}); const [zoom, setZoom] = useState<CSSProperties>({});
const [mounted, setMounted] = useState(app !== null); const [mounted, setMounted] = useState(app !== null);
// A drilled sub-screen registers its back-header here (see useSubHeader); the
// single panel header morphs to back+title instead of app name + close.
const [subHeader, setSubHeader] = useState<SubHeader | null>(null);
const open = app !== null; const open = app !== null;
const onHome = app === "home" || app === null; const onHome = app === "home" || app === null;
// Switching apps (or going home) resets any registered sub-header.
useEffect(() => {
setSubHeader(null);
}, [app]);
// Opening mounts content immediately; closing keeps it mounted through the // Opening mounts content immediately; closing keeps it mounted through the
// collapse transition (flex-basis when leaving phone/tablet, flex-grow when // collapse transition (flex-basis when leaving phone/tablet, flex-grow when
// leaving full), then unmounts. // leaving full), then unmounts.
@@ -111,23 +121,43 @@ export function DevicePanel({ agent }: { agent: Agent }) {
style={zoom} style={zoom}
className="app-shell-anim-launch relative flex-1 overflow-y-auto bg-background/85" className="app-shell-anim-launch relative flex-1 overflow-y-auto bg-background/85"
> >
{/* Top app header lives on the app surface (sticky), like the {/* One sticky header on the app surface (like the reference):
reference: app name + close-X (→ home). Sub-screens render app name + close-X at the top screen; a drilled sub-screen
their own back-header via AppShell. */} morphs it to back-chevron + sub-title via SubHeaderContext. */}
<header className="sticky top-0 z-10 flex shrink-0 items-center gap-2 bg-background/85 px-5 pt-4 pb-3 backdrop-blur-md"> <header className="sticky top-0 z-10 flex shrink-0 items-center gap-2 bg-background/85 px-5 pt-4 pb-3 backdrop-blur-md">
<span className="flex-1 truncate text-lg font-semibold"> {subHeader ? (
{appTitle(app)} <>
</span> <button
<button type="button"
type="button" onClick={subHeader.onBack}
aria-label="Close" className="-my-1 -ml-2 flex min-w-0 items-center gap-1.5 rounded-full py-1.5 pl-2 pr-3 text-left transition-colors hover:bg-neutral-800"
onClick={() => openApp("home")} >
className="flex size-7 items-center justify-center rounded-full text-neutral-300 transition-colors hover:bg-neutral-800" <ChevronLeft aria-hidden size={18} className="shrink-0" />
> <span className="truncate text-lg font-semibold">
<X aria-hidden size={16} /> {subHeader.title}
</button> </span>
</button>
<div className="flex-1" />
</>
) : (
<>
<span className="flex-1 truncate text-lg font-semibold">
{appTitle(app)}
</span>
<button
type="button"
aria-label="Close"
onClick={() => openApp("home")}
className="flex size-7 items-center justify-center rounded-full text-neutral-300 transition-colors hover:bg-neutral-800"
>
<X aria-hidden size={16} />
</button>
</>
)}
</header> </header>
<AppRouter app={app} agent={agent} /> <SubHeaderContext.Provider value={setSubHeader}>
<AppRouter app={app} agent={agent} />
</SubHeaderContext.Provider>
</div> </div>
)} )}
</WallpaperSurface> </WallpaperSurface>
@@ -1,37 +1,32 @@
"use client"; "use client";
import { ChevronLeft } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react"; import { createContext, useContext, useEffect, useRef } from "react";
import { GradientGlyph } from "@/components/ui/GradientGlyph"; import { GradientGlyph } from "@/components/ui/GradientGlyph";
/* Shared panel-app chrome (measured): drilled sub-views get a back-chevron /* When an app drills into a sub-screen it registers a back-header here; the
+ title (+ optional trailing action); top-level apps render their own panel chrome (DevicePanel) then morphs its single header from "app name +
content directly since the panel header already shows the app name. */ close-X" to "back-chevron + sub-title" — one header, like the reference. */
export function SubViewHeader({ export type SubHeader = { title: string; onBack: () => void };
title, export const SubHeaderContext = createContext<(h: SubHeader | null) => void>(
onBack, () => {},
action, );
}: {
title: string; /** Registers `{title, onBack}` with the panel header while `active`. */
onBack: () => void; export function useSubHeader(
action?: ReactNode; active: boolean,
}) { title: string,
return ( onBack: () => void,
<header className="flex items-center gap-2 px-5 pt-4 pb-3"> ) {
<button const setSub = useContext(SubHeaderContext);
type="button" const backRef = useRef(onBack);
onClick={onBack} backRef.current = onBack;
className="-my-1 -ml-2 flex min-w-0 items-center gap-1.5 rounded-full py-1.5 pl-2 pr-3 text-left transition-colors hover:bg-neutral-800" useEffect(() => {
> if (!active) return;
<ChevronLeft aria-hidden size={18} className="shrink-0" /> setSub({ title, onBack: () => backRef.current() });
<span className="truncate text-lg font-semibold">{title}</span> return () => setSub(null);
</button> }, [active, title, setSub]);
<div className="flex-1" />
{action != null && <span>{action}</span>}
</header>
);
} }
/* The shared empty / error state (measured): muted lucide glyph, 16px/600 /* The shared empty / error state (measured): muted lucide glyph, 16px/600
@@ -1,12 +1,12 @@
"use client"; "use client";
import { ChevronLeft, ChevronRight, File, Folder } from "lucide-react"; import { ChevronRight, File, Folder } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { useFetchJson } from "@/lib/api/use-fetch"; import { useFetchJson } from "@/lib/api/use-fetch";
import { GradientGlyph } from "@/components/ui/GradientGlyph"; import { GradientGlyph } from "@/components/ui/GradientGlyph";
import { PanelEmptyState } from "./AppShell"; import { PanelEmptyState, useSubHeader } from "./AppShell";
interface FileNode { interface FileNode {
path: string; path: string;
@@ -33,6 +33,9 @@ export default function FilesApp({ agent }: { agent: Agent }) {
const { data, loading } = useFetchJson<FileNode[]>( const { data, loading } = useFetchJson<FileNode[]>(
drive ? driveUrl(agent.id, drive) : null, drive ? driveUrl(agent.id, drive) : null,
); );
const label = drive ? (DRIVES.find((d) => d.id === drive)?.label ?? "") : "";
// The opened drive drives the panel header (back-chevron + drive name).
useSubHeader(drive !== null, label, () => setDrive(null));
if (drive === null) { if (drive === null) {
return ( return (
@@ -58,16 +61,8 @@ export default function FilesApp({ agent }: { agent: Agent }) {
); );
} }
const label = DRIVES.find((d) => d.id === drive)!.label;
return ( return (
<div className="p-3"> <div className="p-3">
<button
type="button"
onClick={() => setDrive(null)}
className="flex items-center gap-1 pb-2 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronLeft aria-hidden size={14} /> {label}
</button>
{loading ? ( {loading ? (
<p className="px-2 text-xs text-muted-foreground">Loading…</p> <p className="px-2 text-xs text-muted-foreground">Loading…</p>
) : data && data.length > 0 ? ( ) : data && data.length > 0 ? (
@@ -1,12 +1,13 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ChevronLeft, ChevronRight, Trash2 } from "lucide-react"; import { ChevronRight, Trash2 } from "lucide-react";
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
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 { useFetchJson } from "@/lib/api/use-fetch"; import { useFetchJson } from "@/lib/api/use-fetch";
import { useSubHeader } from "./AppShell";
interface SettingsPayload { interface SettingsPayload {
agent: Agent; agent: Agent;
@@ -26,6 +27,8 @@ export default function SettingsApp({ agent }: { agent: Agent }) {
const [screen, setScreen] = useState<Screen>("main"); const [screen, setScreen] = useState<Screen>("main");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false); const [confirmingDelete, setConfirmingDelete] = useState(false);
// The edit sub-screen drives the panel header (back-chevron + "Edit profile").
useSubHeader(screen === "edit", "Edit profile", () => setScreen("main"));
const settings = useFetchJson<SettingsPayload>( const settings = useFetchJson<SettingsPayload>(
`/api/claws/settings/full?clawId=${agent.id}`, `/api/claws/settings/full?clawId=${agent.id}`,
); );
@@ -73,13 +76,6 @@ export default function SettingsApp({ agent }: { agent: Agent }) {
if (screen === "edit") { if (screen === "edit") {
return ( return (
<form onSubmit={saveProfile} className="flex flex-col gap-3 p-3"> <form onSubmit={saveProfile} className="flex flex-col gap-3 p-3">
<button
type="button"
onClick={() => setScreen("main")}
className="flex items-center gap-1 self-start text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronLeft aria-hidden size={14} /> Edit profile
</button>
<label className="flex flex-col gap-1 text-xs text-muted-foreground"> <label className="flex flex-col gap-1 text-xs text-muted-foreground">
Name Name
<input <input