History

Improve mobile chat layout and add responsive sidebar primitives

- add reusable `ui/sidebar` and `ui/tooltip` components for responsive navigation - optimize chat header/composer spacing and mobile-safe touch targets - add sidebar trigger in chat header and hide desktop-only controls on small screens - make session hydration reconnects silent to reduce unnecessary connecting state flicker

Julius Marminge committed 8 months ago

Commit b9bb89c1 parent c03bb9510 files · +964 −69Browse files

Unified · Split

10 files
  1. apps/web/src/components/BranchToolbar.tsx +1 −1
  2. apps/web/src/components/ChatView.tsx +93 −37
  3. apps/web/src/components/Sidebar.tsx +15 −5
  4. apps/web/src/components/ui/sidebar.tsx +698 −0
  5. apps/web/src/components/ui/tooltip.tsx +68 −0
  6. apps/web/src/main.tsx +12 −0
  7. apps/web/src/routes/__root.tsx +1 −1
  8. apps/web/src/routes/_chat.$threadId.tsx +51 −17
  9. apps/web/src/routes/_chat.index.tsx +9 −0
  10. apps/web/src/routes/_chat.tsx +16 −8
apps/web/src/components/BranchToolbar.tsx+1−1
@@ -182,7 +182,7 @@ export default function BranchToolbar({ · 181 unmodified lines
182 if (!activeThread || !activeProject) return null;182 if (!activeThread || !activeProject) return null;
183 183
184 return (184 return (
185 <div className="mx-auto flex w-full max-w-3xl items-center justify-between px-5 pb-3 pt-1">185 <div className="mx-auto flex w-full max-w-3xl items-center justify-between px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-1 sm:px-5 sm:pb-3">
186 <div className="flex items-center gap-2">186 <div className="flex items-center gap-2">
187 {envLocked || activeWorktreePath ? (187 {envLocked || activeWorktreePath ? (
188 <span className="border border-transparent px-[calc(--spacing(2)-1px)] text-sm font-medium text-muted-foreground/70 sm:text-xs">188 <span className="border border-transparent px-[calc(--spacing(2)-1px)] text-sm font-medium text-muted-foreground/70 sm:text-xs">
apps/web/src/components/ChatView.tsx+93−37
@@ -119,6 +119,7 @@ import { · 118 unmodified lines
119 setupProjectScript,119 setupProjectScript,
120} from "~/projectScripts";120} from "~/projectScripts";
121import { Toggle } from "./ui/toggle";121import { Toggle } from "./ui/toggle";
122import { SidebarTrigger } from "./ui/sidebar";
122 123
123function formatMessageMeta(createdAt: string, duration: string | null): string {124function formatMessageMeta(createdAt: string, duration: string | null): string {
124 if (!duration) return formatTimestamp(createdAt);125 if (!duration) return formatTimestamp(createdAt);
@@ -433,6 +434,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 308 unmodified lines
433 );434 );
434 const phase = derivePhase(activeThread?.session ?? null);435 const phase = derivePhase(activeThread?.session ?? null);
435 const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint;436 const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint;
437 const canSend =
438 Boolean(api) &&
439 Boolean(activeThread) &&
440 !isSending &&
441 !isConnecting &&
442 (prompt.trim().length > 0 || composerImages.length > 0);
436 const nowIso = new Date(nowTick).toISOString();443 const nowIso = new Date(nowTick).toISOString();
437 const workLogEntries = useMemo(444 const workLogEntries = useMemo(
438 () => deriveWorkLogEntries(activeThread?.events ?? [], undefined),445 () => deriveWorkLogEntries(activeThread?.events ?? [], undefined),
@@ -1583,7 +1590,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1144 unmodified lines
1583 );1590 );
1584 1591
1585 const ensureSession = useCallback(1592 const ensureSession = useCallback(
1586 async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => {1593 async (
1594 cwdOverride?: string,
1595 options?: {
1596 silent?: boolean;
1597 },
1598 ): Promise<EnsuredSessionInfo | null> => {
1587 if (!api || !activeThread || !activeProject) return null;1599 if (!api || !activeThread || !activeProject) return null;
1588 if (activeThread.session && activeThread.session.status !== "closed") {1600 if (activeThread.session && activeThread.session.status !== "closed") {
1589 const sessionThreadId = activeThread.session.threadId ?? null;1601 const sessionThreadId = activeThread.session.threadId ?? null;
@@ -1606,7 +1618,10 @@ export default function ChatView({ threadId }: ChatViewProps) { · 16 unmodified lines
1606 }1618 }
1607 1619
1608 const priorCodexThreadId = activeThread.codexThreadId;1620 const priorCodexThreadId = activeThread.codexThreadId;
1609 setIsConnecting(true);1621 const shouldShowConnectingState = !options?.silent;
1622 if (shouldShowConnectingState) {
1623 setIsConnecting(true);
1624 }
1610 try {1625 try {
1611 const session = await api.providers.startSession({1626 const session = await api.providers.startSession({
1612 provider: "codex",1627 provider: "codex",
@@ -1649,7 +1664,9 @@ export default function ChatView({ threadId }: ChatViewProps) { · 36 unmodified lines
1649 });1664 });
1650 return null;1665 return null;
1651 } finally {1666 } finally {
1652 setIsConnecting(false);1667 if (shouldShowConnectingState) {
1668 setIsConnecting(false);
1669 }
1653 }1670 }
1654 },1671 },
1655 [1672 [
@@ -1679,7 +1696,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 23 unmodified lines
1679 }1696 }
1680 checkpointHydrationSessionRequestRef.current.add(requestKey);1697 checkpointHydrationSessionRequestRef.current.add(requestKey);
1681 setThreadError(activeThreadId, null);1698 setThreadError(activeThreadId, null);
1682 void ensureSession().then(1699 void ensureSession(undefined, { silent: true }).then(
1683 () => {1700 () => {
1684 checkpointHydrationSessionRequestRef.current.delete(requestKey);1701 checkpointHydrationSessionRequestRef.current.delete(requestKey);
1685 },1702 },
@@ -1904,6 +1921,24 @@ export default function ChatView({ threadId }: ChatViewProps) { · 218 unmodified lines
1904 }1921 }
1905 };1922 };
1906 1923
1924 const triggerSend = () => {
1925 if (!canSend) return;
1926 const form = textareaRef.current?.form;
1927 if (form) {
1928 if (typeof form.requestSubmit === "function") {
1929 form.requestSubmit();
1930 } else {
1931 const submitEvent = new Event("submit", { bubbles: true, cancelable: true });
1932 const dispatched = form.dispatchEvent(submitEvent);
1933 if (!dispatched) {
1934 return;
1935 }
1936 }
1937 return;
1938 }
1939 void onSend({ preventDefault: () => undefined } as FormEvent);
1940 };
1941
1907 const onInterrupt = async () => {1942 const onInterrupt = async () => {
1908 if (!api || !activeThread?.session) return;1943 if (!api || !activeThread?.session) return;
1909 await api.providers.interruptTurn({1944 await api.providers.interruptTurn({
@@ -2047,7 +2082,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 137 unmodified lines
2047 2082
2048 if (e.key === "Enter" && !e.shiftKey) {2083 if (e.key === "Enter" && !e.shiftKey) {
2049 e.preventDefault();2084 e.preventDefault();
2050 void onSend(e as unknown as FormEvent);2085 triggerSend();
2051 }2086 }
2052 };2087 };
2053 const onToggleWorkGroup = useCallback((groupId: string) => {2088 const onToggleWorkGroup = useCallback((groupId: string) => {
@@ -2117,7 +2152,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 63 unmodified lines
2117 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">2152 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
2118 {/* Top bar */}2153 {/* Top bar */}
2119 <header2154 <header
2120 className={`flex items-center justify-between border-b border-border px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`}2155 className={`flex items-center justify-between border-b border-border px-3 sm:px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`}
2121 >2156 >
2122 <ChatHeader2157 <ChatHeader
2123 activeThreadId={activeThread.id}2158 activeThreadId={activeThread.id}
@@ -2151,7 +2186,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 27 unmodified lines
2151 {/* Messages */}2186 {/* Messages */}
2152 <div2187 <div
2153 ref={messagesScrollRef}2188 ref={messagesScrollRef}
2154 className="min-h-0 flex-1 overflow-y-auto px-5 py-4"2189 className="min-h-0 flex-1 overflow-y-auto px-3 py-3 sm:px-5 sm:py-4"
2155 onScroll={onMessagesScroll}2190 onScroll={onMessagesScroll}
2156 >2191 >
2157 <MessagesTimeline2192 <MessagesTimeline
@@ -2176,7 +2211,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 18 unmodified lines
2176 </div>2211 </div>
2177 2212
2178 {/* Input bar */}2213 {/* Input bar */}
2179 <div className={cn("px-5 pt-2", isGitRepo ? "pb-1" : "pb-4")}>2214 <div
2215 className={cn(
2216 "px-3 pt-2 sm:px-5 max-sm:pb-[calc(0.5rem+env(safe-area-inset-bottom))]",
2217 isGitRepo ? "pb-1" : "pb-4",
2218 )}
2219 >
2180 <form onSubmit={onSend} className="mx-auto max-w-3xl">2220 <form onSubmit={onSend} className="mx-auto max-w-3xl">
2181 <div2221 <div
2182 className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${2222 className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${
@@ -2275,8 +2315,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 92 unmodified lines
2275 </div>2315 </div>
2276 2316
2277 {/* Bottom toolbar */}2317 {/* Bottom toolbar */}
2278 <div className="flex items-center justify-between px-3 pb-3">2318 <div className="flex items-center justify-end px-3 pb-3 sm:justify-between">
2279 <div className="flex items-center gap-1">2319 <div className="hidden items-center gap-1 sm:flex">
2280 {/* Model picker */}2320 {/* Model picker */}
2281 <ModelPicker model={selectedModel} onModelChange={onModelSelect} />2321 <ModelPicker model={selectedModel} onModelChange={onModelSelect} />
2282 2322
@@ -2317,7 +2357,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 34 unmodified lines
2317 {phase === "running" ? (2357 {phase === "running" ? (
2318 <button2358 <button
2319 type="button"2359 type="button"
2320 className="flex h-8 w-8 items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105"2360 className="relative z-10 flex h-10 w-10 touch-manipulation items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 pointer-events-auto sm:h-8 sm:w-8 sm:hover:scale-105"
2321 onClick={() => void onInterrupt()}2361 onClick={() => void onInterrupt()}
2322 aria-label="Stop generation"2362 aria-label="Stop generation"
2323 >2363 >
@@ -2333,11 +2373,10 @@ export default function ChatView({ threadId }: ChatViewProps) { · 9 unmodified lines
2333 </button>2373 </button>
2334 ) : (2374 ) : (
2335 <button2375 <button
2336 type="submit"2376 type="button"
2337 className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100"2377 className="relative z-10 flex h-10 w-10 touch-manipulation items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary pointer-events-auto disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8 sm:hover:scale-105"
2338 disabled={2378 disabled={!canSend}
2339 isSending || isConnecting || (!prompt.trim() && composerImages.length === 0)2379 onClick={triggerSend}
2340 }
2341 aria-label={2380 aria-label={
2342 isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"2381 isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"
2343 }2382 }
@@ -2387,13 +2426,15 @@ export default function ChatView({ threadId }: ChatViewProps) { · 43 unmodified lines
2387 </div>2426 </div>
2388 2427
2389 {isGitRepo && (2428 {isGitRepo && (
2390 <BranchToolbar2429 <div className="hidden sm:block">
2391 threadId={activeThread.id}2430 <BranchToolbar
2392 envMode={envMode}2431 threadId={activeThread.id}
2393 onEnvModeChange={onEnvModeChange}2432 envMode={envMode}
2394 envLocked={envLocked}2433 onEnvModeChange={onEnvModeChange}
2395 onComposerFocusRequest={scheduleComposerFocus}2434 envLocked={envLocked}
2396 />2435 onComposerFocusRequest={scheduleComposerFocus}
2436 />
2437 </div>
2397 )}2438 )}
2398 2439
2399 {activeThread.terminalOpen && api && activeProject && (2440 {activeThread.terminalOpen && api && activeProject && (
@@ -2497,28 +2538,43 @@ const ChatHeader = memo(function ChatHeader({ · 97 unmodified lines
2497}: ChatHeaderProps) {2538}: ChatHeaderProps) {
2498 return (2539 return (
2499 <>2540 <>
2500 <div className="flex min-w-0 flex-1 items-center gap-3">2541 <div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
2501 <h2 className="truncate text-sm font-medium text-foreground" title={activeThreadTitle}>2542 <SidebarTrigger className="size-8 md:hidden" />
2543 <Separator orientation="vertical" className="md:hidden data-[orientation=vertical]:h-4" />
2544 <h2
2545 className="truncate text-xs font-medium text-foreground sm:text-sm"
2546 title={activeThreadTitle}
2547 >
2502 {activeThreadTitle}2548 {activeThreadTitle}
2503 </h2>2549 </h2>
2504 {activeProjectName && <Badge variant="outline">{activeProjectName}</Badge>}2550 {activeProjectName && (
2551 <Badge variant="outline" className="hidden sm:inline-flex">
2552 {activeProjectName}
2553 </Badge>
2554 )}
2505 </div>2555 </div>
2506 <div className="shrink-0 flex items-center gap-3">2556 <div className="shrink-0 flex items-center gap-1.5 sm:gap-3">
2507 {activeProjectScripts && (2557 {activeProjectScripts && (
2508 <ProjectScriptsControl2558 <div className="hidden md:block">
2509 scripts={activeProjectScripts}2559 <ProjectScriptsControl
2510 keybindings={keybindings}2560 scripts={activeProjectScripts}
2511 preferredScriptId={preferredScriptId}2561 keybindings={keybindings}
2512 onRunScript={onRunProjectScript}2562 preferredScriptId={preferredScriptId}
2513 onAddScript={onAddProjectScript}2563 onRunScript={onRunProjectScript}
2514 onUpdateScript={onUpdateProjectScript}2564 onAddScript={onAddProjectScript}
2515 />2565 onUpdateScript={onUpdateProjectScript}
2566 />
2567 </div>
2516 )}2568 )}
2517 {activeProjectName && (2569 {activeProjectName && (
2518 <OpenInPicker keybindings={keybindings} activeThreadId={activeThreadId} />2570 <div className="hidden md:block">
2571 <OpenInPicker keybindings={keybindings} activeThreadId={activeThreadId} />
2572 </div>
2519 )}2573 )}
2520 {activeProjectName && (2574 {activeProjectName && (
2521 <GitActionsControl api={api} gitCwd={gitCwd} activeThreadId={activeThreadId} />2575 <div className="hidden md:block">
2576 <GitActionsControl api={api} gitCwd={gitCwd} activeThreadId={activeThreadId} />
2577 </div>
2522 )}2578 )}
2523 <Toggle2579 <Toggle
2524 pressed={diffOpen}2580 pressed={diffOpen}
apps/web/src/components/Sidebar.tsx+15−5
@@ -112,7 +112,11 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null · 111 unmodified lines
112 };112 };
113}113}
114 114
115export default function Sidebar() {115interface SidebarProps {
116 embedded?: boolean;
117}
118
119export default function Sidebar({ embedded = false }: SidebarProps = {}) {
116 const { state, dispatch } = useStore();120 const { state, dispatch } = useStore();
117 const api = useNativeApi();121 const api = useNativeApi();
118 const navigate = useNavigate();122 const navigate = useNavigate();
@@ -497,8 +501,12 @@ export default function Sidebar() { · 378 unmodified lines
497 };501 };
498 }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]);502 }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]);
499 503
504 const rootClassName = embedded
505 ? "sidebar flex h-full min-h-0 flex-col bg-card"
506 : "sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card max-sm:w-[36vw] max-sm:min-w-[140px] max-sm:max-w-[200px]";
507
500 return (508 return (
501 <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card">509 <aside className={rootClassName}>
502 {/* Branding */}510 {/* Branding */}
503 <div511 <div
504 className={`flex items-center gap-2.5 px-4 ${isElectron ? "drag-region h-[52px] pl-[76px]" : "py-4"}`}512 className={`flex items-center gap-2.5 px-4 ${isElectron ? "drag-region h-[52px] pl-[76px]" : "py-4"}`}
@@ -507,7 +515,7 @@ export default function Sidebar() { · 2 unmodified lines
507 <span className="truncate text-sm font-semibold tracking-tight text-foreground">515 <span className="truncate text-sm font-semibold tracking-tight text-foreground">
508 T3 <span className="font-normal text-muted-foreground">Code</span>516 T3 <span className="font-normal text-muted-foreground">Code</span>
509 </span>517 </span>
510 <span className="rounded-full bg-muted/50 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-widest text-muted-foreground/60">518 <span className="hidden rounded-full bg-muted/50 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-widest text-muted-foreground/60 sm:inline-block">
511 {APP_STAGE_LABEL}519 {APP_STAGE_LABEL}
512 </span>520 </span>
513 </div>521 </div>
@@ -563,7 +571,9 @@ export default function Sidebar() { · 49 unmodified lines
563 <span className="flex-1 truncate text-xs font-medium text-foreground/90">571 <span className="flex-1 truncate text-xs font-medium text-foreground/90">
564 {project.name}572 {project.name}
565 </span>573 </span>
566 <span className="text-[10px] text-muted-foreground/60">{threads.length}</span>574 <span className="hidden text-[10px] text-muted-foreground/60 sm:inline">
575 {threads.length}
576 </span>
567 </button>577 </button>
568 578
569 {/* Threads */}579 {/* Threads */}
@@ -627,7 +637,7 @@ export default function Sidebar() { · 57 unmodified lines
627 />637 />
628 </span>638 </span>
629 )}639 )}
630 <span className="text-[10px] text-muted-foreground/40">640 <span className="hidden text-[10px] text-muted-foreground/40 sm:inline">
631 {formatRelativeTime(thread.createdAt)}641 {formatRelativeTime(thread.createdAt)}
632 </span>642 </span>
633 </div>643 </div>
apps/web/src/components/ui/sidebar.tsx+698−0
@@ -0,0 +1,698 @@
1"use client";
2
3import { mergeProps } from "@base-ui/react/merge-props";
4import { useRender } from "@base-ui/react/use-render";
5import { cva, type VariantProps } from "class-variance-authority";
6import { PanelLeftIcon } from "lucide-react";
7import * as React from "react";
8import { cn } from "~/lib/utils";
9import { Button } from "~/components/ui/button";
10import { Input } from "~/components/ui/input";
11import { ScrollArea } from "~/components/ui/scroll-area";
12import { Separator } from "~/components/ui/separator";
13import {
14 Sheet,
15 SheetDescription,
16 SheetHeader,
17 SheetPopup,
18 SheetTitle,
19} from "~/components/ui/sheet";
20import { Skeleton } from "~/components/ui/skeleton";
21import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
22import { useMediaQuery } from "~/hooks/useMediaQuery";
23
24const SIDEBAR_COOKIE_NAME = "sidebar_state";
25const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
26const SIDEBAR_WIDTH = "16rem";
27const SIDEBAR_WIDTH_MOBILE = "18rem";
28const SIDEBAR_WIDTH_ICON = "3rem";
29const SIDEBAR_KEYBOARD_SHORTCUT = "b";
30
31type SidebarContextProps = {
32 state: "expanded" | "collapsed";
33 open: boolean;
34 setOpen: (open: boolean) => void;
35 openMobile: boolean;
36 setOpenMobile: (open: boolean) => void;
37 isMobile: boolean;
38 toggleSidebar: () => void;
39};
40
41const SidebarContext = React.createContext<SidebarContextProps | null>(null);
42
43function useSidebar() {
44 const context = React.useContext(SidebarContext);
45 if (!context) {
46 throw new Error("useSidebar must be used within a SidebarProvider.");
47 }
48
49 return context;
50}
51
52function SidebarProvider({
53 defaultOpen = true,
54 open: openProp,
55 onOpenChange: setOpenProp,
56 className,
57 style,
58 children,
59 ...props
60}: React.ComponentProps<"div"> & {
61 defaultOpen?: boolean;
62 open?: boolean;
63 onOpenChange?: (open: boolean) => void;
64}) {
65 const isMobile = useMediaQuery("(max-width: 767px)");
66 const [openMobile, setOpenMobile] = React.useState(false);
67
68 // This is the internal state of the sidebar.
69 // We use openProp and setOpenProp for control from outside the component.
70 const [_open, _setOpen] = React.useState(defaultOpen);
71 const open = openProp ?? _open;
72 const setOpen = React.useCallback(
73 async (value: boolean | ((value: boolean) => boolean)) => {
74 const openState = typeof value === "function" ? value(open) : value;
75 if (setOpenProp) {
76 setOpenProp(openState);
77 } else {
78 _setOpen(openState);
79 }
80
81 // This sets the cookie to keep the sidebar state.
82 await cookieStore.set({
83 expires: Date.now() + SIDEBAR_COOKIE_MAX_AGE * 1000,
84 name: SIDEBAR_COOKIE_NAME,
85 path: "/",
86 value: String(openState),
87 });
88 },
89 [setOpenProp, open],
90 );
91
92 // Helper to toggle the sidebar.
93 const toggleSidebar = React.useCallback(() => {
94 return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
95 }, [isMobile, setOpen]);
96
97 // Adds a keyboard shortcut to toggle the sidebar.
98 React.useEffect(() => {
99 const handleKeyDown = (event: KeyboardEvent) => {
100 if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
101 event.preventDefault();
102 toggleSidebar();
103 }
104 };
105
106 window.addEventListener("keydown", handleKeyDown);
107 return () => window.removeEventListener("keydown", handleKeyDown);
108 }, [toggleSidebar]);
109
110 // We add a state so that we can do data-state="expanded" or "collapsed".
111 // This makes it easier to style the sidebar with Tailwind classes.
112 const state = open ? "expanded" : "collapsed";
113
114 const contextValue = React.useMemo<SidebarContextProps>(
115 () => ({
116 isMobile,
117 open,
118 openMobile,
119 setOpen,
120 setOpenMobile,
121 state,
122 toggleSidebar,
123 }),
124 [state, open, setOpen, isMobile, openMobile, toggleSidebar],
125 );
126
127 return (
128 <SidebarContext.Provider value={contextValue}>
129 <div
130 className={cn(
131 "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
132 className,
133 )}
134 data-slot="sidebar-wrapper"
135 style={
136 {
137 "--sidebar-width": SIDEBAR_WIDTH,
138 "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
139 ...style,
140 } as React.CSSProperties
141 }
142 {...props}
143 >
144 {children}
145 </div>
146 </SidebarContext.Provider>
147 );
148}
149
150function Sidebar({
151 side = "left",
152 variant = "sidebar",
153 collapsible = "offcanvas",
154 className,
155 children,
156 ...props
157}: React.ComponentProps<"div"> & {
158 side?: "left" | "right";
159 variant?: "sidebar" | "floating" | "inset";
160 collapsible?: "offcanvas" | "icon" | "none";
161}) {
162 const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
163
164 if (collapsible === "none") {
165 return (
166 <div
167 className={cn(
168 "flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
169 className,
170 )}
171 data-slot="sidebar"
172 {...props}
173 >
174 {children}
175 </div>
176 );
177 }
178
179 if (isMobile) {
180 return (
181 <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}>
182 <SheetPopup
183 className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
184 data-mobile="true"
185 data-sidebar="sidebar"
186 data-slot="sidebar"
187 side={side}
188 style={
189 {
190 "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
191 } as React.CSSProperties
192 }
193 >
194 <SheetHeader className="sr-only">
195 <SheetTitle>Sidebar</SheetTitle>
196 <SheetDescription>Displays the mobile sidebar.</SheetDescription>
197 </SheetHeader>
198 <div className="flex h-full w-full flex-col">{children}</div>
199 </SheetPopup>
200 </Sheet>
201 );
202 }
203
204 return (
205 <div
206 className="group peer hidden text-sidebar-foreground md:block"
207 data-collapsible={state === "collapsed" ? collapsible : ""}
208 data-side={side}
209 data-slot="sidebar"
210 data-state={state}
211 data-variant={variant}
212 >
213 {/* This is what handles the sidebar gap on desktop */}
214 <div
215 className={cn(
216 "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
217 "group-data-[collapsible=offcanvas]:w-0",
218 "group-data-[side=right]:rotate-180",
219 variant === "floating" || variant === "inset"
220 ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
221 : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
222 )}
223 data-slot="sidebar-gap"
224 />
225 <div
226 className={cn(
227 "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
228 side === "left"
229 ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
230 : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
231 // Adjust the padding for floating and inset variants.
232 variant === "floating" || variant === "inset"
233 ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
234 : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
235 className,
236 )}
237 data-slot="sidebar-container"
238 {...props}
239 >
240 <div
241 className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm/5"
242 data-sidebar="sidebar"
243 data-slot="sidebar-inner"
244 >
245 {children}
246 </div>
247 </div>
248 </div>
249 );
250}
251
252function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
253 const { toggleSidebar } = useSidebar();
254
255 return (
256 <Button
257 className={cn("size-7", className)}
258 data-sidebar="trigger"
259 data-slot="sidebar-trigger"
260 onClick={(event) => {
261 onClick?.(event);
262 toggleSidebar();
263 }}
264 size="icon"
265 variant="ghost"
266 {...props}
267 >
268 <PanelLeftIcon />
269 <span className="sr-only">Toggle Sidebar</span>
270 </Button>
271 );
272}
273
274function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
275 const { toggleSidebar } = useSidebar();
276
277 return (
278 <button
279 aria-label="Toggle Sidebar"
280 className={cn(
281 "-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex",
282 "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
283 "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
284 "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",
285 "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
286 "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
287 className,
288 )}
289 data-sidebar="rail"
290 data-slot="sidebar-rail"
291 onClick={toggleSidebar}
292 tabIndex={-1}
293 title="Toggle Sidebar"
294 type="button"
295 {...props}
296 />
297 );
298}
299
300function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
301 return (
302 <main
303 className={cn(
304 "relative flex w-full flex-1 flex-col bg-background",
305 "md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm/5",
306 className,
307 )}
308 data-slot="sidebar-inset"
309 {...props}
310 />
311 );
312}
313
314function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
315 return (
316 <Input
317 className={cn("h-8 w-full bg-background shadow-none", className)}
318 data-sidebar="input"
319 data-slot="sidebar-input"
320 {...props}
321 />
322 );
323}
324
325function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
326 return (
327 <div
328 className={cn("flex flex-col gap-2 p-2", className)}
329 data-sidebar="header"
330 data-slot="sidebar-header"
331 {...props}
332 />
333 );
334}
335
336function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
337 return (
338 <div
339 className={cn("flex flex-col gap-2 p-2", className)}
340 data-sidebar="footer"
341 data-slot="sidebar-footer"
342 {...props}
343 />
344 );
345}
346
347function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
348 return (
349 <Separator
350 className={cn("mx-2 w-auto bg-sidebar-border", className)}
351 data-sidebar="separator"
352 data-slot="sidebar-separator"
353 {...props}
354 />
355 );
356}
357
358function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
359 return (
360 <ScrollArea className="**:data-[slot=scroll-area-scrollbar]:hidden" scrollFade>
361 <div
362 className={cn(
363 "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
364 className,
365 )}
366 data-sidebar="content"
367 data-slot="sidebar-content"
368 {...props}
369 />
370 </ScrollArea>
371 );
372}
373
374function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
375 return (
376 <div
377 className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
378 data-sidebar="group"
379 data-slot="sidebar-group"
380 {...props}
381 />
382 );
383}
384
385function SidebarGroupLabel({ className, render, ...props }: useRender.ComponentProps<"div">) {
386 const defaultProps = {
387 className: cn(
388 "flex h-8 shrink-0 items-center rounded-lg px-2 font-medium text-sidebar-foreground text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
389 "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
390 className,
391 ),
392 "data-sidebar": "group-label",
393 "data-slot": "sidebar-group-label",
394 };
395
396 return useRender({
397 defaultTagName: "div",
398 props: mergeProps(defaultProps, props),
399 render,
400 });
401}
402
403function SidebarGroupAction({ className, render, ...props }: useRender.ComponentProps<"button">) {
404 const defaultProps = {
405 className: cn(
406 "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0",
407 // Increases the hit area of the button on mobile.
408 "after:-inset-2 after:absolute md:after:hidden",
409 "group-data-[collapsible=icon]:hidden",
410 className,
411 ),
412 "data-sidebar": "group-action",
413 "data-slot": "sidebar-group-action",
414 };
415
416 return useRender({
417 defaultTagName: "button",
418 props: mergeProps(defaultProps, props),
419 render,
420 });
421}
422
423function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
424 return (
425 <div
426 className={cn("w-full text-sm", className)}
427 data-sidebar="group-content"
428 data-slot="sidebar-group-content"
429 {...props}
430 />
431 );
432}
433
434function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
435 return (
436 <ul
437 className={cn("flex w-full min-w-0 flex-col gap-1", className)}
438 data-sidebar="menu"
439 data-slot="sidebar-menu"
440 {...props}
441 />
442 );
443}
444
445function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
446 return (
447 <li
448 className={cn("group/menu-item relative", className)}
449 data-sidebar="menu-item"
450 data-slot="sidebar-menu-item"
451 {...props}
452 />
453 );
454}
455
456const sidebarMenuButtonVariants = cva(
457 "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0",
458 {
459 defaultVariants: {
460 size: "default",
461 variant: "default",
462 },
463 variants: {
464 size: {
465 default: "h-8 text-sm",
466 lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
467 sm: "h-7 text-xs",
468 },
469 variant: {
470 default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
471 outline:
472 "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
473 },
474 },
475 },
476);
477
478function SidebarMenuButton({
479 isActive = false,
480 variant = "default",
481 size = "default",
482 tooltip,
483 className,
484 render,
485 ...props
486}: useRender.ComponentProps<"button"> & {
487 isActive?: boolean;
488 tooltip?: string | React.ComponentProps<typeof TooltipPopup>;
489} & VariantProps<typeof sidebarMenuButtonVariants>) {
490 const { isMobile, state } = useSidebar();
491
492 const defaultProps = {
493 className: cn(sidebarMenuButtonVariants({ size, variant }), className),
494 "data-active": isActive,
495 "data-sidebar": "menu-button",
496 "data-size": size,
497 "data-slot": "sidebar-menu-button",
498 };
499
500 const buttonProps = mergeProps<"button">(defaultProps, props);
501
502 const buttonElement = useRender({
503 defaultTagName: "button",
504 props: buttonProps,
505 render,
506 });
507
508 if (!tooltip) {
509 return buttonElement;
510 }
511
512 if (typeof tooltip === "string") {
513 tooltip = {
514 children: tooltip,
515 };
516 }
517
518 return (
519 <Tooltip>
520 <TooltipTrigger render={buttonElement as React.ReactElement<Record<string, unknown>>} />
521 <TooltipPopup
522 align="center"
523 hidden={state !== "collapsed" || isMobile}
524 side="right"
525 {...tooltip}
526 />
527 </Tooltip>
528 );
529}
530
531function SidebarMenuAction({
532 className,
533 showOnHover = false,
534 render,
535 ...props
536}: useRender.ComponentProps<"button"> & {
537 showOnHover?: boolean;
538}) {
539 const defaultProps = {
540 className: cn(
541 "absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0",
542 // Increases the hit area of the button on mobile.
543 "after:-inset-2 after:absolute md:after:hidden",
544 "peer-data-[size=sm]/menu-button:top-1",
545 "peer-data-[size=default]/menu-button:top-1.5",
546 "peer-data-[size=lg]/menu-button:top-2.5",
547 "group-data-[collapsible=icon]:hidden",
548 showOnHover &&
549 "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
550 className,
551 ),
552 "data-sidebar": "menu-action",
553 "data-slot": "sidebar-menu-action",
554 };
555
556 return useRender({
557 defaultTagName: "button",
558 props: mergeProps<"button">(defaultProps, props),
559 render,
560 });
561}
562
563function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) {
564 return (
565 <div
566 className={cn(
567 "pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-lg px-1 font-medium text-sidebar-foreground text-xs tabular-nums",
568 "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
569 "peer-data-[size=sm]/menu-button:top-1",
570 "peer-data-[size=default]/menu-button:top-1.5",
571 "peer-data-[size=lg]/menu-button:top-2.5",
572 "group-data-[collapsible=icon]:hidden",
573 className,
574 )}
575 data-sidebar="menu-badge"
576 data-slot="sidebar-menu-badge"
577 {...props}
578 />
579 );
580}
581
582function SidebarMenuSkeleton({
583 className,
584 showIcon = false,
585 ...props
586}: React.ComponentProps<"div"> & {
587 showIcon?: boolean;
588}) {
589 // Random width between 50 to 90%.
590 const width = React.useMemo(() => {
591 return `${Math.floor(Math.random() * 40) + 50}%`;
592 }, []);
593
594 return (
595 <div
596 className={cn("flex h-8 items-center gap-2 rounded-lg px-2", className)}
597 data-sidebar="menu-skeleton"
598 data-slot="sidebar-menu-skeleton"
599 {...props}
600 >
601 {showIcon && <Skeleton className="size-4 rounded-lg" data-sidebar="menu-skeleton-icon" />}
602 <Skeleton
603 className="h-4 max-w-(--skeleton-width) flex-1"
604 data-sidebar="menu-skeleton-text"
605 style={
606 {
607 "--skeleton-width": width,
608 } as React.CSSProperties
609 }
610 />
611 </div>
612 );
613}
614
615function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
616 return (
617 <ul
618 className={cn(
619 "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-sidebar-border border-l px-2.5 py-0.5",
620 "group-data-[collapsible=icon]:hidden",
621 className,
622 )}
623 data-sidebar="menu-sub"
624 data-slot="sidebar-menu-sub"
625 {...props}
626 />
627 );
628}
629
630function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
631 return (
632 <li
633 className={cn("group/menu-sub-item relative", className)}
634 data-sidebar="menu-sub-item"
635 data-slot="sidebar-menu-sub-item"
636 {...props}
637 />
638 );
639}
640
641function SidebarMenuSubButton({
642 size = "md",
643 isActive = false,
644 className,
645 render,
646 ...props
647}: useRender.ComponentProps<"a"> & {
648 size?: "sm" | "md";
649 isActive?: boolean;
650}) {
651 const defaultProps = {
652 className: cn(
653 "-translate-x-px flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-lg px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
654 "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
655 size === "sm" && "text-xs",
656 size === "md" && "text-sm",
657 "group-data-[collapsible=icon]:hidden",
658 className,
659 ),
660 "data-active": isActive,
661 "data-sidebar": "menu-sub-button",
662 "data-size": size,
663 "data-slot": "sidebar-menu-sub-button",
664 };
665
666 return useRender({
667 defaultTagName: "a",
668 props: mergeProps<"a">(defaultProps, props),
669 render,
670 });
671}
672
673export {
674 Sidebar,
675 SidebarContent,
676 SidebarFooter,
677 SidebarGroup,
678 SidebarGroupAction,
679 SidebarGroupContent,
680 SidebarGroupLabel,
681 SidebarHeader,
682 SidebarInput,
683 SidebarInset,
684 SidebarMenu,
685 SidebarMenuAction,
686 SidebarMenuBadge,
687 SidebarMenuButton,
688 SidebarMenuItem,
689 SidebarMenuSkeleton,
690 SidebarMenuSub,
691 SidebarMenuSubButton,
692 SidebarMenuSubItem,
693 SidebarProvider,
694 SidebarRail,
695 SidebarSeparator,
696 SidebarTrigger,
697 useSidebar,
698};
apps/web/src/components/ui/tooltip.tsx+68−0
@@ -0,0 +1,68 @@
1"use client";
2
3import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
4
5import { cn } from "~/lib/utils";
6
7const TooltipCreateHandle = TooltipPrimitive.createHandle;
8
9const TooltipProvider = TooltipPrimitive.Provider;
10
11const Tooltip = TooltipPrimitive.Root;
12
13function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) {
14 return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
15}
16
17function TooltipPopup({
18 className,
19 align = "center",
20 sideOffset = 4,
21 side = "top",
22 anchor,
23 children,
24 ...props
25}: TooltipPrimitive.Popup.Props & {
26 align?: TooltipPrimitive.Positioner.Props["align"];
27 side?: TooltipPrimitive.Positioner.Props["side"];
28 sideOffset?: TooltipPrimitive.Positioner.Props["sideOffset"];
29 anchor?: TooltipPrimitive.Positioner.Props["anchor"];
30}) {
31 return (
32 <TooltipPrimitive.Portal>
33 <TooltipPrimitive.Positioner
34 align={align}
35 anchor={anchor}
36 className="z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom,transform] data-instant:transition-none"
37 data-slot="tooltip-positioner"
38 side={side}
39 sideOffset={sideOffset}
40 >
41 <TooltipPrimitive.Popup
42 className={cn(
43 "relative flex h-(--popup-height,auto) w-(--popup-width,auto) origin-(--transform-origin) text-balance rounded-md border bg-popover not-dark:bg-clip-padding text-popover-foreground text-xs shadow-md/5 transition-[width,height,scale,opacity] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-md)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:duration-0 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
44 className,
45 )}
46 data-slot="tooltip-popup"
47 {...props}
48 >
49 <TooltipPrimitive.Viewport
50 className="relative size-full overflow-clip px-(--viewport-inline-padding) py-1 [--viewport-inline-padding:--spacing(2)] data-instant:transition-none **:data-current:data-ending-style:opacity-0 **:data-current:data-starting-style:opacity-0 **:data-previous:data-ending-style:opacity-0 **:data-previous:data-starting-style:opacity-0 **:data-current:w-[calc(var(--popup-width)-2*var(--viewport-inline-padding)-2px)] **:data-previous:w-[calc(var(--popup-width)-2*var(--viewport-inline-padding)-2px)] **:data-previous:truncate **:data-current:opacity-100 **:data-previous:opacity-100 **:data-current:transition-opacity **:data-previous:transition-opacity"
51 data-slot="tooltip-viewport"
52 >
53 {children}
54 </TooltipPrimitive.Viewport>
55 </TooltipPrimitive.Popup>
56 </TooltipPrimitive.Positioner>
57 </TooltipPrimitive.Portal>
58 );
59}
60
61export {
62 TooltipCreateHandle,
63 TooltipProvider,
64 Tooltip,
65 TooltipTrigger,
66 TooltipPopup,
67 TooltipPopup as TooltipContent,
68};
apps/web/src/main.tsx+12−0
@@ -13,6 +13,18 @@ import { APP_DISPLAY_NAME } from "./branding"; · 12 unmodified lines
13import { isElectron } from "./env";13import { isElectron } from "./env";
14import { routeTree } from "./routeTree.gen";14import { routeTree } from "./routeTree.gen";
15 15
16if (!globalThis.crypto.randomUUID) {
17 // https://stackoverflow.com/a/2117523/2800218
18 // LICENSE: https://creativecommons.org/licenses/by-sa/4.0/legalcode
19 // oxlint-disable-next-line unicorn/consistent-function-scoping
20 crypto.randomUUID = function randomUUID() {
21 // @ts-expect-error -- Some cryptic stuff that this polyfill uses
22 return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
23 (c ^ (crypto.getRandomValues(new Uint8Array(1))[0]! & (15 >> (c / 4)))).toString(16),
24 );
25 };
26}
27
16const history = isElectron ? createHashHistory() : createBrowserHistory();28const history = isElectron ? createHashHistory() : createBrowserHistory();
17 29
18const queryClient = new QueryClient();30const queryClient = new QueryClient();
apps/web/src/routes/__root.tsx+1−1
@@ -28,7 +28,7 @@ function RootRouteView() { · 27 unmodified lines
28 28
29 if (!api) {29 if (!api) {
30 return (30 return (
31 <div className="flex h-screen flex-col bg-background text-foreground">31 <div className="flex h-dvh flex-col bg-background text-foreground">
32 <div className="flex flex-1 items-center justify-center">32 <div className="flex flex-1 items-center justify-center">
33 <p className="text-sm text-muted-foreground">33 <p className="text-sm text-muted-foreground">
34 Connecting to {APP_DISPLAY_NAME} server...34 Connecting to {APP_DISPLAY_NAME} server...
apps/web/src/routes/_chat.$threadId.tsx+51−17
@@ -1,11 +1,12 @@
1import { createFileRoute, useNavigate } from "@tanstack/react-router";1import { createFileRoute, useNavigate } from "@tanstack/react-router";
2import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react";2import { Suspense, lazy, type CSSProperties, type ReactNode, useCallback, useEffect } from "react";
3 3
4import ChatView from "../components/ChatView";4import ChatView from "../components/ChatView";
5import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";5import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
6import { useMediaQuery } from "../hooks/useMediaQuery";6import { useMediaQuery } from "../hooks/useMediaQuery";
7import { useStore } from "../store";7import { useStore } from "../store";
8import { Sheet, SheetPopup } from "../components/ui/sheet";8import { Sheet, SheetPopup } from "../components/ui/sheet";
9import { Sidebar, SidebarProvider } from "../components/ui/sidebar";
9 10
10const DiffPanel = lazy(() => import("../components/DiffPanel"));11const DiffPanel = lazy(() => import("../components/DiffPanel"));
11const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";12const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
@@ -53,6 +54,20 @@ function ChatThreadRouteView() { · 41 unmodified lines
53 const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false;54 const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false;
54 const diffOpen = search.diff === "1";55 const diffOpen = search.diff === "1";
55 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);56 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
57 const setDiffOpen = useCallback(
58 (open: boolean) => {
59 if (open === diffOpen) return;
60 void navigate({
61 to: "/$threadId",
62 params: { threadId },
63 search: (previous) => {
64 const rest = stripDiffSearchParams(previous);
65 return open ? { ...rest, diff: "1" } : rest;
66 },
67 });
68 },
69 [diffOpen, navigate, threadId],
70 );
56 const closeDiff = useCallback(() => {71 const closeDiff = useCallback(() => {
57 void navigate({72 void navigate({
58 to: "/$threadId",73 to: "/$threadId",
@@ -83,26 +98,45 @@ function ChatThreadRouteView() { · 24 unmodified lines
83 return null;98 return null;
84 }99 }
85 100
86 const diffLoadingFallback =101 const diffLoadingFallback = (
87 !diffOpen || shouldUseDiffSheet ? (102 <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70">
88 <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70">103 Loading diff viewer...
89 Loading diff viewer...104 </div>
90 </div>105 );
91 ) : (106
92 <aside className="flex h-full w-[560px] shrink-0 items-center justify-center border-l border-border bg-card px-4 text-center text-xs text-muted-foreground/70">107 if (shouldUseDiffSheet) {
93 Loading diff viewer...108 return (
94 </aside>109 <>
110 <ChatView threadId={threadId} />
111 <DiffPanelWrapper sheet diffOpen={diffOpen} onCloseDiff={closeDiff}>
112 <Suspense fallback={diffLoadingFallback}>
113 <DiffPanel mode="sheet" />
114 </Suspense>
115 </DiffPanelWrapper>
116 </>
95 );117 );
118 }
96 119
97 return (120 return (
98 <>121 <div className="flex min-h-0 min-w-0 flex-1">
99 <ChatView threadId={threadId} />122 <ChatView threadId={threadId} />
100 <DiffPanelWrapper sheet={shouldUseDiffSheet} diffOpen={diffOpen} onCloseDiff={closeDiff}>123 <SidebarProvider
101 <Suspense fallback={diffLoadingFallback}>124 open={diffOpen}
102 <DiffPanel mode={shouldUseDiffSheet ? "sheet" : "inline"} />125 onOpenChange={setDiffOpen}
103 </Suspense>126 className="w-0 min-w-0 flex-none"
104 </DiffPanelWrapper>127 style={{ "--sidebar-width": "34rem" } as CSSProperties}
105 </>128 >
129 <Sidebar
130 side="right"
131 collapsible="offcanvas"
132 className="border-l border-border bg-card text-foreground"
133 >
134 <Suspense fallback={diffLoadingFallback}>
135 <DiffPanel mode="sheet" />
136 </Suspense>
137 </Sidebar>
138 </SidebarProvider>
139 </div>
106 );140 );
107}141}
108 142
apps/web/src/routes/_chat.index.tsx+9−0
@@ -1,10 +1,19 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2 2
3import { isElectron } from "../env";3import { isElectron } from "../env";
4import { Separator } from "../components/ui/separator";
5import { SidebarTrigger } from "../components/ui/sidebar";
4 6
5function ChatIndexRouteView() {7function ChatIndexRouteView() {
6 return (8 return (
7 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40">9 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40">
10 {!isElectron && (
11 <header className="sticky top-0 z-20 flex h-11 shrink-0 items-center gap-2 border-b border-border/70 bg-background/95 px-2 backdrop-blur md:hidden">
12 <SidebarTrigger className="size-8" />
13 <Separator orientation="vertical" className="data-[orientation=vertical]:h-4" />
14 <span className="truncate text-xs text-muted-foreground/75">Threads</span>
15 </header>
16 )}
8 {isElectron && (17 {isElectron && (
9 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">18 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
10 <span className="text-xs text-muted-foreground/50">No active thread</span>19 <span className="text-xs text-muted-foreground/50">No active thread</span>
apps/web/src/routes/_chat.tsx+16−8
@@ -2,7 +2,8 @@ import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; · 1 unmodified line
2import { useEffect } from "react";2import { useEffect } from "react";
3 3
4import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";4import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
5import Sidebar from "../components/Sidebar";5import ThreadSidebar from "../components/Sidebar";
6import { Sidebar, SidebarInset, SidebarProvider } from "../components/ui/sidebar";
6 7
7function ChatRouteLayout() {8function ChatRouteLayout() {
8 const navigate = useNavigate();9 const navigate = useNavigate();
@@ -24,16 +25,23 @@ function ChatRouteLayout() { · 15 unmodified lines
24 }, [navigate]);25 }, [navigate]);
25 26
26 return (27 return (
27 <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">28 <SidebarProvider defaultOpen>
28 <Sidebar />29 <Sidebar
29 <DiffWorkerPoolProvider>30 side="left"
30 <Outlet />31 collapsible="offcanvas"
31 </DiffWorkerPoolProvider>32 className="border-r border-border bg-card text-foreground"
32 </div>33 >
34 <ThreadSidebar embedded />
35 </Sidebar>
36 <SidebarInset className="h-dvh min-h-0 overflow-hidden bg-background text-foreground isolate">
37 <DiffWorkerPoolProvider>
38 <Outlet />
39 </DiffWorkerPoolProvider>
40 </SidebarInset>
41 </SidebarProvider>
33 );42 );
34}43}
35 44
36export const Route = createFileRoute("/_chat")({45export const Route = createFileRoute("/_chat")({
37 component: ChatRouteLayout,46 component: ChatRouteLayout,
38});47});
39