pr_01m47d15m3e54sn21z27rpy5n9/apps/web/app/components/shell.tsx

684 lines25,485 bytesCodeBlame
1import {
2 BarChart3,
3 BookMarked,
4 BookOpen,
5 Check,
6 ChevronsUpDown,
7 CircleDot,
8 Code2,
9 Compass,
10 CornerDownLeft,
11 CreditCard,
12 GitPullRequest,
13 History,
14 KeyRound,
15 Layers,
16 LayoutDashboard,
17 ListTree,
18 Lock,
19 LogOut,
20 Menu,
21 Plus,
22 Search,
23 Settings,
24 Users,
25 X,
26} from "lucide-react";
27import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
28import { Form, Link, NavLink, useLocation, useNavigate, useNavigation, useSubmit } from "react-router";
29
30import type { User } from "@g1t/contracts";
31import { MICROS_PER_DOLLAR } from "@g1t/contracts";
32
33import { Logo } from "./logo";
34import { Avatar } from "./ui";
35import {
36 DropdownMenu,
37 DropdownMenuContent,
38 DropdownMenuItem,
39 DropdownMenuLabel,
40 DropdownMenuSeparator,
41 DropdownMenuTrigger,
42} from "./ui/dropdown-menu";
43
44/** What the sidebar needs, worked out by the root loader for a signed-in person. */
45export type ShellData = {
46 /** The workspace the sidebar is about: the one being looked at, or their first. */
47 workspace: { slug: string; role: "owner" | "member" } | null;
48 /** Its repositories, newest first. */
49 repos: { namespace: string; name: string; isPrivate: boolean }[];
50 /** The repository being looked at, if any, whoever owns it. */
51 repo: {
52 namespace: string;
53 name: string;
54 member: boolean;
55 issues: number;
56 pulls: number;
57 } | null;
58 /** The workspace's agent credit, if billing is on and they may see it. */
59 creditMicros: number | null;
60 /** What its agents have cost since the start of the month. */
61 monthSpentMicros: number | null;
62};
63
64function SidebarLink({
65 to,
66 icon,
67 end,
68 count,
69 also,
70 children,
71}: {
72 to: string;
73 icon: ReactNode;
74 end?: boolean;
75 count?: number;
76 /** Another path prefix under which this link is the current one. */
77 also?: string;
78 children: ReactNode;
79}) {
80 const { pathname } = useLocation();
81 return (
82 <NavLink
83 to={to}
84 end={end}
85 prefetch="intent"
86 className={({ isActive, isPending }) => {
87 const current = isActive || (also != null && pathname.startsWith(also + "/"));
88 return `group flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors ${
89 current
90 ? "bg-raised font-medium text-fg"
91 : isPending
92 ? "bg-raised/60 text-fg"
93 : "text-muted hover:bg-raised/60 hover:text-fg"
94 }`;
95 }}
96 >
97 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
98 <span className="min-w-0 grow truncate">{children}</span>
99 {count != null && count > 0 && (
100 <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
101 {count}
102 </span>
103 )}
104 </NavLink>
105 );
106}
107
108function SidebarGroup({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
109 return (
110 <section className="mt-5">
111 <div className="mb-1 flex items-center justify-between px-2">
112 <h2 className="text-[0.6875rem] font-medium tracking-wider text-faint uppercase">
113 {title}
114 </h2>
115 {action}
116 </div>
117 <div className="space-y-px">{children}</div>
118 </section>
119 );
120}
121
122function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) {
123 const current = shell.workspace?.slug;
124 return (
125 <DropdownMenu>
126 <DropdownMenuTrigger className="flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-left outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised">
127 {current ? (
128 <Avatar name={current} size={26} square />
129 ) : (
130 <span className="size-6.5 rounded-md border border-dashed border-line-strong" />
131 )}
132 <span className="min-w-0 grow">
133 <span className="block truncate text-sm font-semibold">{current ?? "No workspace"}</span>
134 <span className="block truncate text-xs text-faint">
135 {shell.workspace?.role ?? "Create one to start"}
136 </span>
137 </span>
138 <ChevronsUpDown size={14} className="shrink-0 text-faint" />
139 </DropdownMenuTrigger>
140 <DropdownMenuContent align="start" className="w-60">
141 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
142 {(user.workspaces ?? []).map((membership) => (
143 <DropdownMenuItem asChild key={membership.slug}>
144 <Link to={`/${membership.slug}`}>
145 <Avatar name={membership.slug} size={16} square />
146 <span className="grow">{membership.slug}</span>
147 {membership.slug === current && <Check className="text-accent" />}
148 </Link>
149 </DropdownMenuItem>
150 ))}
151 <DropdownMenuSeparator />
152 <DropdownMenuItem asChild>
153 <Link to="/workspaces/new">
154 <Plus />
155 New workspace
156 </Link>
157 </DropdownMenuItem>
158 </DropdownMenuContent>
159 </DropdownMenu>
160 );
161}
162
163function Sidebar({ user, shell }: { user: User; shell: ShellData }) {
164 const ws = shell.workspace;
165 const active = shell.repo;
166 const repoBase = active ? `/${active.namespace}/${active.name}` : null;
167 // The repository being looked at is listed even when it is someone else's.
168 const listed =
169 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
170 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
171 : shell.repos;
172 return (
173 <div className="flex h-full flex-col">
174 <div className="flex h-12 items-center gap-2 px-4">
175 <Link to="/" aria-label="g1t home">
176 <Logo />
177 </Link>
178 </div>
179 <div className="px-2">
180 <WorkspaceSwitcher user={user} shell={shell} />
181 </div>
182 <nav className="min-h-0 grow overflow-y-auto px-2 pb-4">
183 <div className="mt-3 space-y-px">
184 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
185 Mission control
186 </SidebarLink>
187 <SidebarLink to="/explore" icon={<Compass size={15} />}>
188 Explore
189 </SidebarLink>
190 </div>
191
192 {ws && (
193 <SidebarGroup title="Workspace">
194 <SidebarLink to={`/${ws.slug}`} end icon={<LayoutDashboard size={15} />}>
195 Overview
196 </SidebarLink>
197 <SidebarLink to={`/${ws.slug}/-/people`} icon={<Users size={15} />}>
198 People
199 </SidebarLink>
200 <SidebarLink to={`/${ws.slug}/-/tokens`} icon={<KeyRound size={15} />}>
201 Access tokens
202 </SidebarLink>
203 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
204 Usage
205 </SidebarLink>
206 <SidebarLink to={`/${ws.slug}/-/billing`} icon={<CreditCard size={15} />}>
207 Billing
208 </SidebarLink>
209 {ws.role === "owner" && (
210 <SidebarLink to={`/${ws.slug}/-/settings`} icon={<Settings size={15} />}>
211 Settings
212 </SidebarLink>
213 )}
214 </SidebarGroup>
215 )}
216
217 <SidebarGroup
218 title="Repositories"
219 action={
220 ws && (
221 <Link
222 to={`/new?workspace=${ws.slug}`}
223 aria-label="New repository"
224 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
225 >
226 <Plus size={13} />
227 </Link>
228 )
229 }
230 >
231 {listed.length === 0 && (
232 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
233 )}
234 {listed.map((repo) => {
235 const open =
236 active && repo.namespace === active.namespace && repo.name === active.name;
237 const base = `/${repo.namespace}/${repo.name}`;
238 return (
239 <div key={base}>
240 <SidebarLink
241 to={base}
242 end={!open}
243 icon={repo.isPrivate ? <Lock size={15} /> : <BookMarked size={15} />}
244 >
245 <span className="font-mono text-[0.8125rem]">
246 {repo.namespace !== ws?.slug && (
247 <span className="text-faint">{repo.namespace}/</span>
248 )}
249 {repo.name}
250 </span>
251 </SidebarLink>
252 {open && repoBase && (
253 <div className="my-0.5 ml-4 space-y-px border-l border-line pl-2">
254 <SidebarLink to={repoBase} end icon={<Code2 size={14} />} also={`${repoBase}/tree`}>
255 Code
256 </SidebarLink>
257 <SidebarLink
258 to={`${repoBase}/issues`}
259 icon={<CircleDot size={14} />}
260 count={active.issues}
261 >
262 Issues
263 </SidebarLink>
264 <SidebarLink
265 to={`${repoBase}/pulls`}
266 also={`${repoBase}/pull`}
267 icon={<GitPullRequest size={14} />}
268 count={active.pulls}
269 >
270 Pull requests
271 </SidebarLink>
272 <SidebarLink to={`${repoBase}/queue`} icon={<Layers size={14} />}>
273 Merge queue
274 </SidebarLink>
275 <SidebarLink
276 to={`${repoBase}/commits`}
277 also={`${repoBase}/commit`}
278 icon={<History size={14} />}
279 >
280 Commits
281 </SidebarLink>
282 {active.member && (
283 <SidebarLink to={`${repoBase}/plans`} icon={<ListTree size={14} />}>
284 Plan
285 </SidebarLink>
286 )}
287 {active.member && (
288 <SidebarLink to={`${repoBase}/settings`} icon={<Settings size={14} />}>
289 Settings
290 </SidebarLink>
291 )}
292 </div>
293 )}
294 </div>
295 );
296 })}
297 </SidebarGroup>
298 </nav>
299
300 <div className="border-t border-line p-2">
301 {ws && shell.monthSpentMicros != null && (
302 <Link
303 to={`/${ws.slug}/-/usage`}
304 className="mb-1 block rounded-md px-2 py-1.5 text-sm text-muted transition-colors hover:bg-raised hover:text-fg"
305 >
306 <span className="flex items-center gap-2.5">
307 <BarChart3 size={15} className="shrink-0 text-faint" />
308 <span className="grow">Usage this month</span>
309 <span className="font-mono text-xs text-fg tabular-nums">
310 ${(shell.monthSpentMicros / MICROS_PER_DOLLAR).toFixed(2)}
311 </span>
312 </span>
313 {shell.creditMicros != null && (
314 <span
315 className={`mt-0.5 block pl-6.5 text-xs ${shell.creditMicros <= 0 ? "text-warn" : "text-faint"}`}
316 >
317 ${(shell.creditMicros / MICROS_PER_DOLLAR).toFixed(2)} of credit left
318 </span>
319 )}
320 </Link>
321 )}
322 <a
323 href="https://docs.g1t.sh/"
324 className="flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-muted transition-colors hover:bg-raised hover:text-fg"
325 >
326 <BookOpen size={15} className="shrink-0 text-faint" />
327 Documentation
328 </a>
329 </div>
330 </div>
331 );
332}
333
334type Command = { label: string; hint?: string; to: string; icon: ReactNode };
335
336/** Everything the palette can jump to, from what the sidebar already knows. */
337function commandsFor(user: User, shell: ShellData): Command[] {
338 const commands: Command[] = [
339 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
340 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
341 { label: "New repository", to: "/new", icon: <Plus size={15} /> },
342 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
343 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
344 ];
345 const repo = shell.repo;
346 if (repo) {
347 const base = `/${repo.namespace}/${repo.name}`;
348 const name = `${repo.namespace}/${repo.name}`;
349 commands.unshift(
350 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
351 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
352 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
353 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
354 ...(repo.member
355 ? [
356 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
357 { label: "Repository settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
358 ]
359 : []),
360 );
361 }
362 for (const membership of user.workspaces ?? []) {
363 commands.push(
364 { label: membership.slug, hint: "Workspace", to: `/${membership.slug}`, icon: <Avatar name={membership.slug} size={15} square /> },
365 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
366 { label: "Billing", hint: membership.slug, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
367 { label: "Access tokens", hint: membership.slug, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
368 );
369 }
370 for (const listed of shell.repos) {
371 commands.push({
372 label: `${listed.namespace}/${listed.name}`,
373 hint: "Repository",
374 to: `/${listed.namespace}/${listed.name}`,
375 icon: <BookMarked size={15} />,
376 });
377 }
378 return commands;
379}
380
381function CommandPalette({
382 open,
383 onClose,
384 commands,
385}: {
386 open: boolean;
387 onClose: () => void;
388 commands: Command[];
389}) {
390 const navigate = useNavigate();
391 const [query, setQuery] = useState("");
392 const [selected, setSelected] = useState(0);
393 const input = useRef<HTMLInputElement>(null);
394 const matches = useMemo(() => {
395 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
396 return commands
397 .filter((command) => {
398 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
399 return words.every((word) => text.includes(word));
400 })
401 .slice(0, 12);
402 }, [commands, query]);
403
404 useEffect(() => {
405 if (!open) return;
406 setQuery("");
407 setSelected(0);
408 input.current?.focus();
409 }, [open]);
410 useEffect(() => setSelected(0), [query]);
411
412 if (!open) return null;
413 const go = (command: Command | undefined) => {
414 if (!command) return;
415 onClose();
416 navigate(command.to);
417 };
418 return (
419 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
420 <div
421 role="dialog"
422 aria-label="Go to"
423 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
424 onClick={(event) => event.stopPropagation()}
425 >
426 <div className="flex items-center gap-3 border-b border-line px-4">
427 <Search size={16} className="text-faint" />
428 <input
429 ref={input}
430 value={query}
431 onChange={(event) => setQuery(event.target.value)}
432 onKeyDown={(event) => {
433 if (event.key === "ArrowDown") {
434 event.preventDefault();
435 setSelected((index) => Math.min(index + 1, matches.length - 1));
436 } else if (event.key === "ArrowUp") {
437 event.preventDefault();
438 setSelected((index) => Math.max(index - 1, 0));
439 } else if (event.key === "Enter") {
440 event.preventDefault();
441 go(matches[selected]);
442 } else if (event.key === "Escape") {
443 onClose();
444 }
445 }}
446 placeholder="Go to a repository, a page, or an action…"
447 autoComplete="off"
448 data-1p-ignore
449 className="h-12 grow border-0 bg-transparent text-sm shadow-none outline-none ring-0 placeholder:text-faint focus:outline-none focus-visible:outline-none"
450 />
451 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
452 </div>
453 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
454 {matches.length === 0 && (
455 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
456 )}
457 {matches.map((command, index) => (
458 <li key={`${command.to}-${command.label}`}>
459 <button
460 type="button"
461 onMouseEnter={() => setSelected(index)}
462 onClick={() => go(command)}
463 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
464 index === selected ? "bg-line text-fg" : "text-muted"
465 }`}
466 >
467 <span className="shrink-0 text-faint">{command.icon}</span>
468 <span className="min-w-0 grow truncate">{command.label}</span>
469 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
470 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
471 </button>
472 </li>
473 ))}
474 </ul>
475 </div>
476 </div>
477 );
478}
479
480/**
481 * A bar across the top of the page while the next one loads, as GitHub
482 * has: it appears at once, creeps towards the end while waiting, then fills
483 * and fades when the page arrives.
484 */
485export function Progress() {
486 const navigation = useNavigation();
487 const busy = navigation.state !== "idle";
488 const [width, setWidth] = useState(0);
489 const [visible, setVisible] = useState(false);
490 useEffect(() => {
491 if (busy) {
492 setVisible(true);
493 setWidth(12);
494 // Slows as it goes, never quite arriving until the page does.
495 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
496 return () => clearInterval(timer);
497 }
498 setWidth((now) => (now > 0 ? 100 : 0));
499 const fade = setTimeout(() => setVisible(false), 250);
500 const reset = setTimeout(() => setWidth(0), 550);
501 return () => {
502 clearTimeout(fade);
503 clearTimeout(reset);
504 };
505 }, [busy]);
506 return (
507 <div
508 aria-hidden="true"
509 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
510 visible ? "opacity-100" : "opacity-0"
511 }`}
512 >
513 <div
514 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
515 style={{ width: `${width}%` }}
516 />
517 </div>
518 );
519}
520
521/**
522 * The signed-in app: a sidebar with the workspace, its repositories and the
523 * sections of the one being looked at; a slim bar with search and the
524 * account; and the page.
525 */
526export function AppShell({
527 user,
528 shell,
529 banner,
530 children,
531}: {
532 user: User;
533 shell: ShellData;
534 banner?: ReactNode;
535 children: ReactNode;
536}) {
537 const submit = useSubmit();
538 const { pathname } = useLocation();
539 const [drawer, setDrawer] = useState(false);
540 const [palette, setPalette] = useState(false);
541 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
542
543 // A new page closes the drawer on small screens.
544 useEffect(() => setDrawer(false), [pathname]);
545 useEffect(() => {
546 const onKey = (event: KeyboardEvent) => {
547 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
548 event.preventDefault();
549 setPalette((open) => !open);
550 }
551 };
552 window.addEventListener("keydown", onKey);
553 return () => window.removeEventListener("keydown", onKey);
554 }, []);
555
556 return (
557 <div className="min-h-screen">
558 <Progress />
559 <aside className="fixed inset-y-0 left-0 z-40 hidden w-64 border-r border-line bg-[color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))] lg:block">
560 <Sidebar user={user} shell={shell} />
561 </aside>
562 {drawer && (
563 <div className="fixed inset-0 z-50 lg:hidden">
564 <button
565 type="button"
566 aria-label="Close menu"
567 className="absolute inset-0 bg-black/60"
568 onClick={() => setDrawer(false)}
569 />
570 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
571 <button
572 type="button"
573 aria-label="Close menu"
574 onClick={() => setDrawer(false)}
575 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
576 >
577 <X size={16} />
578 </button>
579 <Sidebar user={user} shell={shell} />
580 </aside>
581 </div>
582 )}
583
584 <div className="flex min-h-screen flex-col lg:pl-64">
585 <header className="sticky top-0 z-30 flex h-12 items-center gap-2 border-b border-line bg-bg/85 px-3 backdrop-blur sm:px-4">
586 <button
587 type="button"
588 aria-label="Open menu"
589 onClick={() => setDrawer(true)}
590 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
591 >
592 <Menu size={18} />
593 </button>
594 <button
595 type="button"
596 onClick={() => setPalette(true)}
597 className="flex h-8 w-full max-w-md items-center gap-2 rounded-md border border-line bg-surface/60 px-2.5 text-sm text-faint transition-colors hover:border-line-strong hover:text-muted"
598 >
599 <Search size={14} />
600 <span className="grow text-left">Search or jump to…</span>
601 <kbd className="hidden rounded border border-line px-1.5 font-mono text-[0.6875rem] sm:inline">⌘K</kbd>
602 </button>
603 <Form action="/search" className="hidden" />
604 <div className="ml-auto flex items-center gap-1.5">
605 <DropdownMenu>
606 <DropdownMenuTrigger
607 aria-label="Create"
608 className="rounded-md border border-line p-1.5 text-muted outline-none transition-colors hover:border-line-strong hover:text-fg data-[state=open]:bg-raised"
609 >
610 <Plus size={15} />
611 </DropdownMenuTrigger>
612 <DropdownMenuContent align="end">
613 {shell.repo && (
614 <DropdownMenuItem asChild>
615 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
616 <CircleDot />
617 New issue
618 </Link>
619 </DropdownMenuItem>
620 )}
621 {shell.repo?.member && (
622 <DropdownMenuItem asChild>
623 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
624 <ListTree />
625 Plan work
626 </Link>
627 </DropdownMenuItem>
628 )}
629 <DropdownMenuItem asChild>
630 <Link to="/new">
631 <BookMarked />
632 New repository
633 </Link>
634 </DropdownMenuItem>
635 <DropdownMenuItem asChild>
636 <Link to="/workspaces/new">
637 <Users />
638 New workspace
639 </Link>
640 </DropdownMenuItem>
641 </DropdownMenuContent>
642 </DropdownMenu>
643 <DropdownMenu>
644 <DropdownMenuTrigger
645 aria-label="Account menu"
646 className="rounded-md p-1 outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised"
647 >
648 <Avatar name={user.username} size={24} />
649 </DropdownMenuTrigger>
650 <DropdownMenuContent align="end" className="w-56">
651 <DropdownMenuLabel>
652 Signed in as <span className="font-mono font-medium text-fg">{user.username}</span>
653 </DropdownMenuLabel>
654 <DropdownMenuSeparator />
655 <DropdownMenuItem asChild>
656 <Link to="/settings">
657 <Settings />
658 Your settings
659 </Link>
660 </DropdownMenuItem>
661 <DropdownMenuItem asChild>
662 <a href="https://docs.g1t.sh/">
663 <BookOpen />
664 Documentation
665 </a>
666 </DropdownMenuItem>
667 <DropdownMenuSeparator />
668 {/* Submitted from here: the menu closes on select, and a button
669 that has left the page cannot submit a form. */}
670 <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}>
671 <LogOut />
672 Sign out
673 </DropdownMenuItem>
674 </DropdownMenuContent>
675 </DropdownMenu>
676 </div>
677 </header>
678 {banner}
679 <main className="grow">{children}</main>
680 </div>
681 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
682 </div>
683 );
684}