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:
co-authored by
Claude Opus 4.8
parent
95df0f3a98
commit
65a0f1aab9
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user