shell+ai: pristine-style nav groups, mobile fixes for /ai
Sidenav (app-shell.tsx): - Each NavGroup now carries an icon (Building2 / Database / Plug / MessageSquare / Eye / Sparkles) rendered on the LEFT of the group header, with the chevron moved to the RIGHT. Header typography switched to caption + uppercase + tracking-wider muted, matching pristine-ui's main-branch app-shell. Same change applied to the mobile sheet's group headers. /ai mobile fixes (ai.tsx): - Composer container honors iOS safe-area inset (pb-[max(0.75rem,env(safe-area-inset-bottom))]) so the input clears the home indicator and stays above the soft keyboard. - Composer toolbar wraps on narrow viewports (flex-wrap + gap-y-1) so the agent / model / reasoning / voice chips don't clip. - Empty-state card uses px-4 sm:px-8 instead of hard px-8. - MessageRow's 56px turn-number gutter collapses below sm: prose flows full-width on phone, two-column layout returns at sm+. /ai desktop centering: - Console wrapper opts out of AppShell's [&>*:first-child]:lg:pr-72 (the page-header clearance for the floating top-right pill) via lg:!pr-0. The /ai surface has no top-right page-header controls, so the inherited padding was shifting the chat column ~144px left of the visible viewport center. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,11 @@ import {
|
|||||||
Megaphone,
|
Megaphone,
|
||||||
AlertOctagon,
|
AlertOctagon,
|
||||||
SearchCode,
|
SearchCode,
|
||||||
ChevronRight,
|
ChevronDown,
|
||||||
|
Database,
|
||||||
|
Plug,
|
||||||
|
MessageSquare,
|
||||||
|
Eye,
|
||||||
// CREMA:NAV-ICONS
|
// CREMA:NAV-ICONS
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
@@ -102,6 +106,7 @@ type NavItem = {
|
|||||||
type NavGroup = {
|
type NavGroup = {
|
||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
|
icon: React.ComponentType<{ className?: string }>
|
||||||
items: NavItem[]
|
items: NavItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +124,7 @@ const navGroups: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
key: "tenancy",
|
key: "tenancy",
|
||||||
label: "Tenancy",
|
label: "Tenancy",
|
||||||
|
icon: Building2,
|
||||||
items: [
|
items: [
|
||||||
{ to: "/tenants", icon: Building2, label: "Tenants" },
|
{ to: "/tenants", icon: Building2, label: "Tenants" },
|
||||||
{ to: "/memberships", icon: UserCheck, label: "Memberships" },
|
{ to: "/memberships", icon: UserCheck, label: "Memberships" },
|
||||||
@@ -129,6 +135,7 @@ const navGroups: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
key: "data",
|
key: "data",
|
||||||
label: "Data",
|
label: "Data",
|
||||||
|
icon: Database,
|
||||||
items: [
|
items: [
|
||||||
{ to: "/storage", icon: HardDrive, label: "Storage" },
|
{ to: "/storage", icon: HardDrive, label: "Storage" },
|
||||||
{ to: "/buckets", icon: Boxes, label: "Buckets" },
|
{ to: "/buckets", icon: Boxes, label: "Buckets" },
|
||||||
@@ -138,6 +145,7 @@ const navGroups: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
key: "integrations",
|
key: "integrations",
|
||||||
label: "Integrations",
|
label: "Integrations",
|
||||||
|
icon: Plug,
|
||||||
items: [
|
items: [
|
||||||
{ to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
|
{ to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
|
||||||
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
|
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
|
||||||
@@ -147,6 +155,7 @@ const navGroups: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
key: "comms",
|
key: "comms",
|
||||||
label: "Communications",
|
label: "Communications",
|
||||||
|
icon: MessageSquare,
|
||||||
items: [
|
items: [
|
||||||
{ to: "/announcements", icon: Megaphone, label: "Announcements" },
|
{ to: "/announcements", icon: Megaphone, label: "Announcements" },
|
||||||
{ to: "/status-page", icon: AlertOctagon, label: "Status page" },
|
{ to: "/status-page", icon: AlertOctagon, label: "Status page" },
|
||||||
@@ -155,6 +164,7 @@ const navGroups: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
key: "observability",
|
key: "observability",
|
||||||
label: "Observability",
|
label: "Observability",
|
||||||
|
icon: Eye,
|
||||||
items: [
|
items: [
|
||||||
{ to: "/monitoring", icon: Gauge, label: "Monitoring" },
|
{ to: "/monitoring", icon: Gauge, label: "Monitoring" },
|
||||||
{ to: "/activity", icon: Activity, label: "Audit log" },
|
{ to: "/activity", icon: Activity, label: "Audit log" },
|
||||||
@@ -163,6 +173,7 @@ const navGroups: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
key: "ai",
|
key: "ai",
|
||||||
label: "AI & Search",
|
label: "AI & Search",
|
||||||
|
icon: Sparkles,
|
||||||
items: [
|
items: [
|
||||||
{ to: "/ai", icon: Bot, label: "AI" },
|
{ to: "/ai", icon: Bot, label: "AI" },
|
||||||
{ to: "/search", icon: SearchCode, label: "Search" },
|
{ to: "/search", icon: SearchCode, label: "Search" },
|
||||||
@@ -332,22 +343,24 @@ export function AppShell({
|
|||||||
|
|
||||||
{navGroups.map((group) => {
|
{navGroups.map((group) => {
|
||||||
const isOpen = !!openGroups[group.key]
|
const isOpen = !!openGroups[group.key]
|
||||||
|
const GroupIcon = group.icon
|
||||||
return (
|
return (
|
||||||
<div key={group.key} className="mt-1.5 flex flex-col gap-0.5">
|
<div key={group.key} className="mt-3 flex flex-col gap-0.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-action={`nav-group-${group.key}`}
|
data-action={`nav-group-${group.key}`}
|
||||||
onClick={() => toggleGroup(group.key)}
|
onClick={() => toggleGroup(group.key)}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground/80 transition-colors hover:text-foreground"
|
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-left text-caption font-semibold uppercase tracking-wider text-muted-foreground/70 transition-colors duration-fast ease-standard hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ChevronRight
|
<GroupIcon className="size-3.5 shrink-0" />
|
||||||
|
<span className="flex-1 truncate">{group.label}</span>
|
||||||
|
<ChevronDown
|
||||||
className={[
|
className={[
|
||||||
"size-3 shrink-0 transition-transform duration-fast ease-standard",
|
"size-3.5 shrink-0 transition-transform duration-fast ease-standard",
|
||||||
isOpen ? "rotate-90" : "",
|
isOpen ? "" : "-rotate-90",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
/>
|
/>
|
||||||
<span className="truncate">{group.label}</span>
|
|
||||||
</button>
|
</button>
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
@@ -440,22 +453,24 @@ export function AppShell({
|
|||||||
|
|
||||||
{navGroups.map((group) => {
|
{navGroups.map((group) => {
|
||||||
const isOpen = !!openGroups[group.key]
|
const isOpen = !!openGroups[group.key]
|
||||||
|
const GroupIcon = group.icon
|
||||||
return (
|
return (
|
||||||
<div key={group.key} className="mt-1.5 flex flex-col gap-0.5">
|
<div key={group.key} className="mt-3 flex flex-col gap-0.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-action={`nav-mobile-group-${group.key}`}
|
data-action={`nav-mobile-group-${group.key}`}
|
||||||
onClick={() => toggleGroup(group.key)}
|
onClick={() => toggleGroup(group.key)}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground/80 transition-colors hover:text-foreground"
|
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-left text-caption font-semibold uppercase tracking-wider text-muted-foreground/70 transition-colors duration-fast ease-standard hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ChevronRight
|
<GroupIcon className="size-3.5 shrink-0" />
|
||||||
|
<span className="flex-1 truncate">{group.label}</span>
|
||||||
|
<ChevronDown
|
||||||
className={[
|
className={[
|
||||||
"size-3 shrink-0 transition-transform duration-fast ease-standard",
|
"size-3.5 shrink-0 transition-transform duration-fast ease-standard",
|
||||||
isOpen ? "rotate-90" : "",
|
isOpen ? "" : "-rotate-90",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
/>
|
/>
|
||||||
<span className="truncate">{group.label}</span>
|
|
||||||
</button>
|
</button>
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
|
|||||||
@@ -630,7 +630,7 @@ export default function AIRoute() {
|
|||||||
* toggle still works for them). */}
|
* toggle still works for them). */}
|
||||||
<div
|
<div
|
||||||
data-theme="console"
|
data-theme="console"
|
||||||
className="-m-6 flex h-full min-h-0 flex-col bg-[var(--console-ink)] text-[var(--console-text)]"
|
className="-m-6 flex h-full min-h-0 flex-col bg-[var(--console-ink)] text-[var(--console-text)] lg:!pr-0"
|
||||||
>
|
>
|
||||||
<ChatSurface
|
<ChatSurface
|
||||||
models={availableModels}
|
models={availableModels}
|
||||||
@@ -1222,7 +1222,7 @@ function ChatSurface({
|
|||||||
{/* Empty state — flight-recorder card with staggered reveal */}
|
{/* Empty state — flight-recorder card with staggered reveal */}
|
||||||
<div
|
<div
|
||||||
aria-hidden={!isEmpty}
|
aria-hidden={!isEmpty}
|
||||||
className="pointer-events-none absolute inset-x-0 top-[10%] px-8 transition-opacity duration-300"
|
className="pointer-events-none absolute inset-x-0 top-[10%] px-4 transition-opacity duration-300 sm:px-8"
|
||||||
style={{ opacity: isEmpty ? 1 : 0 }}
|
style={{ opacity: isEmpty ? 1 : 0 }}
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex max-w-3xl flex-col gap-4">
|
<div className="mx-auto flex max-w-3xl flex-col gap-4">
|
||||||
@@ -1363,7 +1363,7 @@ function ChatSurface({
|
|||||||
* center when empty, then springs to sticky-bottom on the first message. */}
|
* center when empty, then springs to sticky-bottom on the first message. */}
|
||||||
<div
|
<div
|
||||||
ref={composerRef}
|
ref={composerRef}
|
||||||
className="sticky bottom-0 z-20 px-4 pb-3 pt-3 sm:px-6"
|
className="sticky bottom-0 z-20 px-4 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:px-6"
|
||||||
style={{
|
style={{
|
||||||
transform: isEmpty
|
transform: isEmpty
|
||||||
? "translateY(calc(-50dvh + 50% + 4rem))"
|
? "translateY(calc(-50dvh + 50% + 4rem))"
|
||||||
@@ -1630,8 +1630,8 @@ function MessageRow({
|
|||||||
// row hangs from a left gutter showing the turn number.
|
// row hangs from a left gutter showing the turn number.
|
||||||
if (role === "user") {
|
if (role === "user") {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[3.5rem_1fr] gap-x-3 self-stretch">
|
<div className="grid grid-cols-1 gap-x-3 self-stretch sm:grid-cols-[3.5rem_1fr]">
|
||||||
<div className="flex flex-col items-end pt-[3px]">
|
<div className="hidden flex-col items-end pt-[3px] sm:flex">
|
||||||
<span className="console-turn-num">
|
<span className="console-turn-num">
|
||||||
T{(turnNum ?? 0).toString().padStart(2, "0")}
|
T{(turnNum ?? 0).toString().padStart(2, "0")}
|
||||||
</span>
|
</span>
|
||||||
@@ -1641,7 +1641,7 @@ function MessageRow({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="border-l border-[var(--console-rule-soft)] pl-4">
|
<div className="sm:border-l sm:border-[var(--console-rule-soft)] sm:pl-4">
|
||||||
<div className="console-op-line whitespace-pre-wrap">
|
<div className="console-op-line whitespace-pre-wrap">
|
||||||
<span className="console-op-prompt">› </span>
|
<span className="console-op-prompt">› </span>
|
||||||
{content}
|
{content}
|
||||||
@@ -1655,8 +1655,8 @@ function MessageRow({
|
|||||||
// there's no prose (just tool calls), suppress the row entirely.
|
// there's no prose (just tool calls), suppress the row entirely.
|
||||||
if (!content.trim()) return null
|
if (!content.trim()) return null
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[3.5rem_1fr] gap-x-3 self-stretch">
|
<div className="grid grid-cols-1 gap-x-3 self-stretch sm:grid-cols-[3.5rem_1fr]">
|
||||||
<div className="flex flex-col items-end pt-[2px]">
|
<div className="hidden flex-col items-end pt-[2px] sm:flex">
|
||||||
<span className="console-turn-num text-[var(--console-cyan)]">
|
<span className="console-turn-num text-[var(--console-cyan)]">
|
||||||
T{(turnNum ?? 0).toString().padStart(2, "0")}
|
T{(turnNum ?? 0).toString().padStart(2, "0")}
|
||||||
</span>
|
</span>
|
||||||
@@ -1664,7 +1664,7 @@ function MessageRow({
|
|||||||
{agentName?.slice(0, 6).toLowerCase() ?? "atlas"}
|
{agentName?.slice(0, 6).toLowerCase() ?? "atlas"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-l border-[var(--console-cyan-deep)]/40 pl-4">
|
<div className="sm:border-l sm:border-[var(--console-cyan-deep)]/40 sm:pl-4">
|
||||||
<div className="console-agent-prose">
|
<div className="console-agent-prose">
|
||||||
<MessageBody content={content} toolCalls={toolCalls} />
|
<MessageBody content={content} toolCalls={toolCalls} />
|
||||||
</div>
|
</div>
|
||||||
@@ -1781,7 +1781,7 @@ function Composer({
|
|||||||
className="min-h-[3.5rem] w-full resize-none bg-transparent outline-none"
|
className="min-h-[3.5rem] w-full resize-none bg-transparent outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Reference in New Issue
Block a user