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
182182 if (!activeThread || !activeProject) return null;
183183
184184 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">
186186 <div className="flex items-center gap-2">
187187 {envLocked || activeWorktreePath ? (
188188 <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
119119 setupProjectScript,
120120 } from "~/projectScripts";
121121 import { Toggle } from "./ui/toggle";
122+import { SidebarTrigger } from "./ui/sidebar";
122123
123124 function formatMessageMeta(createdAt: string, duration: string | null): string {
124125 if (!duration) return formatTimestamp(createdAt);
@@ -433,6 +434,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 308 unmodified lines
433434 );
434435 const phase = derivePhase(activeThread?.session ?? null);
435436 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);
436443 const nowIso = new Date(nowTick).toISOString();
437444 const workLogEntries = useMemo(
438445 () => deriveWorkLogEntries(activeThread?.events ?? [], undefined),
@@ -1583,7 +1590,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1144 unmodified lines
15831590 );
15841591
15851592 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> => {
15871599 if (!api || !activeThread || !activeProject) return null;
15881600 if (activeThread.session && activeThread.session.status !== "closed") {
15891601 const sessionThreadId = activeThread.session.threadId ?? null;
@@ -1606,7 +1618,10 @@ export default function ChatView({ threadId }: ChatViewProps) { · 16 unmodified lines
16061618 }
16071619
16081620 const priorCodexThreadId = activeThread.codexThreadId;
1609− setIsConnecting(true);
1621+ const shouldShowConnectingState = !options?.silent;
1622+ if (shouldShowConnectingState) {
1623+ setIsConnecting(true);
1624+ }
16101625 try {
16111626 const session = await api.providers.startSession({
16121627 provider: "codex",
@@ -1649,7 +1664,9 @@ export default function ChatView({ threadId }: ChatViewProps) { · 36 unmodified lines
16491664 });
16501665 return null;
16511666 } finally {
1652− setIsConnecting(false);
1667+ if (shouldShowConnectingState) {
1668+ setIsConnecting(false);
1669+ }
16531670 }
16541671 },
16551672 [
@@ -1679,7 +1696,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 23 unmodified lines
16791696 }
16801697 checkpointHydrationSessionRequestRef.current.add(requestKey);
16811698 setThreadError(activeThreadId, null);
1682− void ensureSession().then(
1699+ void ensureSession(undefined, { silent: true }).then(
16831700 () => {
16841701 checkpointHydrationSessionRequestRef.current.delete(requestKey);
16851702 },
@@ -1904,6 +1921,24 @@ export default function ChatView({ threadId }: ChatViewProps) { · 218 unmodified lines
19041921 }
19051922 };
19061923
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+
19071942 const onInterrupt = async () => {
19081943 if (!api || !activeThread?.session) return;
19091944 await api.providers.interruptTurn({
@@ -2047,7 +2082,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 137 unmodified lines
20472082
20482083 if (e.key === "Enter" && !e.shiftKey) {
20492084 e.preventDefault();
2050− void onSend(e as unknown as FormEvent);
2085+ triggerSend();
20512086 }
20522087 };
20532088 const onToggleWorkGroup = useCallback((groupId: string) => {
@@ -2117,7 +2152,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 63 unmodified lines
21172152 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
21182153 {/* Top bar */}
21192154 <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"}`}
21212156 >
21222157 <ChatHeader
21232158 activeThreadId={activeThread.id}
@@ -2151,7 +2186,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 27 unmodified lines
21512186 {/* Messages */}
21522187 <div
21532188 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"
21552190 onScroll={onMessagesScroll}
21562191 >
21572192 <MessagesTimeline
@@ -2176,7 +2211,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 18 unmodified lines
21762211 </div>
21772212
21782213 {/* 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+ >
21802220 <form onSubmit={onSend} className="mx-auto max-w-3xl">
21812221 <div
21822222 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
22752315 </div>
22762316
22772317 {/* Bottom toolbar */}
2278− <div className="flex items-center justify-between px-3 pb-3">
2279− <div className="flex items-center gap-1">
2318+ <div className="flex items-center justify-end px-3 pb-3 sm:justify-between">
2319+ <div className="hidden items-center gap-1 sm:flex">
22802320 {/* Model picker */}
22812321 <ModelPicker model={selectedModel} onModelChange={onModelSelect} />
22822322
@@ -2317,7 +2357,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 34 unmodified lines
23172357 {phase === "running" ? (
23182358 <button
23192359 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"
23212361 onClick={() => void onInterrupt()}
23222362 aria-label="Stop generation"
23232363 >
@@ -2333,11 +2373,10 @@ export default function ChatView({ threadId }: ChatViewProps) { · 9 unmodified lines
23332373 </button>
23342374 ) : (
23352375 <button
2336− type="submit"
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"
2338− disabled={
2339− isSending || isConnecting || (!prompt.trim() && composerImages.length === 0)
2340− }
2376+ type="button"
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"
2378+ disabled={!canSend}
2379+ onClick={triggerSend}
23412380 aria-label={
23422381 isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"
23432382 }
@@ -2387,13 +2426,15 @@ export default function ChatView({ threadId }: ChatViewProps) { · 43 unmodified lines
23872426 </div>
23882427
23892428 {isGitRepo && (
2390− <BranchToolbar
2391− threadId={activeThread.id}
2392− envMode={envMode}
2393− onEnvModeChange={onEnvModeChange}
2394− envLocked={envLocked}
2395− onComposerFocusRequest={scheduleComposerFocus}
2396− />
2429+ <div className="hidden sm:block">
2430+ <BranchToolbar
2431+ threadId={activeThread.id}
2432+ envMode={envMode}
2433+ onEnvModeChange={onEnvModeChange}
2434+ envLocked={envLocked}
2435+ onComposerFocusRequest={scheduleComposerFocus}
2436+ />
2437+ </div>
23972438 )}
23982439
23992440 {activeThread.terminalOpen && api && activeProject && (
@@ -2497,28 +2538,43 @@ const ChatHeader = memo(function ChatHeader({ · 97 unmodified lines
24972538 }: ChatHeaderProps) {
24982539 return (
24992540 <>
2500− <div className="flex min-w-0 flex-1 items-center gap-3">
2501− <h2 className="truncate text-sm font-medium text-foreground" title={activeThreadTitle}>
2541+ <div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
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+ >
25022548 {activeThreadTitle}
25032549 </h2>
2504− {activeProjectName && <Badge variant="outline">{activeProjectName}</Badge>}
2550+ {activeProjectName && (
2551+ <Badge variant="outline" className="hidden sm:inline-flex">
2552+ {activeProjectName}
2553+ </Badge>
2554+ )}
25052555 </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">
25072557 {activeProjectScripts && (
2508− <ProjectScriptsControl
2509− scripts={activeProjectScripts}
2510− keybindings={keybindings}
2511− preferredScriptId={preferredScriptId}
2512− onRunScript={onRunProjectScript}
2513− onAddScript={onAddProjectScript}
2514− onUpdateScript={onUpdateProjectScript}
2515− />
2558+ <div className="hidden md:block">
2559+ <ProjectScriptsControl
2560+ scripts={activeProjectScripts}
2561+ keybindings={keybindings}
2562+ preferredScriptId={preferredScriptId}
2563+ onRunScript={onRunProjectScript}
2564+ onAddScript={onAddProjectScript}
2565+ onUpdateScript={onUpdateProjectScript}
2566+ />
2567+ </div>
25162568 )}
25172569 {activeProjectName && (
2518− <OpenInPicker keybindings={keybindings} activeThreadId={activeThreadId} />
2570+ <div className="hidden md:block">
2571+ <OpenInPicker keybindings={keybindings} activeThreadId={activeThreadId} />
2572+ </div>
25192573 )}
25202574 {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>
25222578 )}
25232579 <Toggle
25242580 pressed={diffOpen}
apps/web/src/components/Sidebar.tsx+15−5
@@ -112,7 +112,11 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null · 111 unmodified lines
112112 };
113113 }
114114
115−export default function Sidebar() {
115+interface SidebarProps {
116+ embedded?: boolean;
117+}
118+
119+export default function Sidebar({ embedded = false }: SidebarProps = {}) {
116120 const { state, dispatch } = useStore();
117121 const api = useNativeApi();
118122 const navigate = useNavigate();
@@ -497,8 +501,12 @@ export default function Sidebar() { · 378 unmodified lines
497501 };
498502 }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]);
499503
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+
500508 return (
501− <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card">
509+ <aside className={rootClassName}>
502510 {/* Branding */}
503511 <div
504512 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
507515 <span className="truncate text-sm font-semibold tracking-tight text-foreground">
508516 T3 <span className="font-normal text-muted-foreground">Code</span>
509517 </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">
511519 {APP_STAGE_LABEL}
512520 </span>
513521 </div>
@@ -563,7 +571,9 @@ export default function Sidebar() { · 49 unmodified lines
563571 <span className="flex-1 truncate text-xs font-medium text-foreground/90">
564572 {project.name}
565573 </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>
567577 </button>
568578
569579 {/* Threads */}
@@ -627,7 +637,7 @@ export default function Sidebar() { · 57 unmodified lines
627637 />
628638 </span>
629639 )}
630− <span className="text-[10px] text-muted-foreground/40">
640+ <span className="hidden text-[10px] text-muted-foreground/40 sm:inline">
631641 {formatRelativeTime(thread.createdAt)}
632642 </span>
633643 </div>
apps/web/src/components/ui/sidebar.tsx+698−0
@@ -0,0 +1,698 @@
1+"use client";
2+
3+import { mergeProps } from "@base-ui/react/merge-props";
4+import { useRender } from "@base-ui/react/use-render";
5+import { cva, type VariantProps } from "class-variance-authority";
6+import { PanelLeftIcon } from "lucide-react";
7+import * as React from "react";
8+import { cn } from "~/lib/utils";
9+import { Button } from "~/components/ui/button";
10+import { Input } from "~/components/ui/input";
11+import { ScrollArea } from "~/components/ui/scroll-area";
12+import { Separator } from "~/components/ui/separator";
13+import {
14+ Sheet,
15+ SheetDescription,
16+ SheetHeader,
17+ SheetPopup,
18+ SheetTitle,
19+} from "~/components/ui/sheet";
20+import { Skeleton } from "~/components/ui/skeleton";
21+import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
22+import { useMediaQuery } from "~/hooks/useMediaQuery";
23+
24+const SIDEBAR_COOKIE_NAME = "sidebar_state";
25+const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
26+const SIDEBAR_WIDTH = "16rem";
27+const SIDEBAR_WIDTH_MOBILE = "18rem";
28+const SIDEBAR_WIDTH_ICON = "3rem";
29+const SIDEBAR_KEYBOARD_SHORTCUT = "b";
30+
31+type 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+
41+const SidebarContext = React.createContext<SidebarContextProps | null>(null);
42+
43+function 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+
52+function 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+
150+function 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+
252+function 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+
274+function 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+
300+function 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+
314+function 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+
325+function 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+
336+function 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+
347+function 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+
358+function 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+
374+function 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+
385+function 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+
403+function 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+
423+function 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+
434+function 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+
445+function 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+
456+const 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+
478+function 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+
531+function 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+
563+function 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+
582+function 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+
615+function 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+
630+function 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+
641+function 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+
673+export {
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+
3+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
4+
5+import { cn } from "~/lib/utils";
6+
7+const TooltipCreateHandle = TooltipPrimitive.createHandle;
8+
9+const TooltipProvider = TooltipPrimitive.Provider;
10+
11+const Tooltip = TooltipPrimitive.Root;
12+
13+function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) {
14+ return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
15+}
16+
17+function 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+
61+export {
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
1313 import { isElectron } from "./env";
1414 import { routeTree } from "./routeTree.gen";
1515
16+if (!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+
1628 const history = isElectron ? createHashHistory() : createBrowserHistory();
1729
1830 const queryClient = new QueryClient();
apps/web/src/routes/__root.tsx+1−1
@@ -28,7 +28,7 @@ function RootRouteView() { · 27 unmodified lines
2828
2929 if (!api) {
3030 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">
3232 <div className="flex flex-1 items-center justify-center">
3333 <p className="text-sm text-muted-foreground">
3434 Connecting to {APP_DISPLAY_NAME} server...
apps/web/src/routes/_chat.$threadId.tsx+51−17
@@ -1,11 +1,12 @@
11 import { createFileRoute, useNavigate } from "@tanstack/react-router";
2−import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react";
2+import { Suspense, lazy, type CSSProperties, type ReactNode, useCallback, useEffect } from "react";
33
44 import ChatView from "../components/ChatView";
55 import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
66 import { useMediaQuery } from "../hooks/useMediaQuery";
77 import { useStore } from "../store";
88 import { Sheet, SheetPopup } from "../components/ui/sheet";
9+import { Sidebar, SidebarProvider } from "../components/ui/sidebar";
910
1011 const DiffPanel = lazy(() => import("../components/DiffPanel"));
1112 const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
@@ -53,6 +54,20 @@ function ChatThreadRouteView() { · 41 unmodified lines
5354 const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false;
5455 const diffOpen = search.diff === "1";
5556 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+ );
5671 const closeDiff = useCallback(() => {
5772 void navigate({
5873 to: "/$threadId",
@@ -83,26 +98,45 @@ function ChatThreadRouteView() { · 24 unmodified lines
8398 return null;
8499 }
85100
86− const diffLoadingFallback =
87− !diffOpen || shouldUseDiffSheet ? (
88− <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70">
89− Loading diff viewer...
90− </div>
91− ) : (
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">
93− Loading diff viewer...
94− </aside>
101+ const diffLoadingFallback = (
102+ <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...
104+ </div>
105+ );
106+
107+ if (shouldUseDiffSheet) {
108+ return (
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+ </>
95117 );
118+ }
96119
97120 return (
98− <>
121+ <div className="flex min-h-0 min-w-0 flex-1">
99122 <ChatView threadId={threadId} />
100− <DiffPanelWrapper sheet={shouldUseDiffSheet} diffOpen={diffOpen} onCloseDiff={closeDiff}>
101− <Suspense fallback={diffLoadingFallback}>
102− <DiffPanel mode={shouldUseDiffSheet ? "sheet" : "inline"} />
103− </Suspense>
104− </DiffPanelWrapper>
105− </>
123+ <SidebarProvider
124+ open={diffOpen}
125+ onOpenChange={setDiffOpen}
126+ className="w-0 min-w-0 flex-none"
127+ style={{ "--sidebar-width": "34rem" } as CSSProperties}
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>
106140 );
107141 }
108142
apps/web/src/routes/_chat.index.tsx+9−0
@@ -1,10 +1,19 @@
11 import { createFileRoute } from "@tanstack/react-router";
22
33 import { isElectron } from "../env";
4+import { Separator } from "../components/ui/separator";
5+import { SidebarTrigger } from "../components/ui/sidebar";
46
57 function ChatIndexRouteView() {
68 return (
79 <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+ )}
817 {isElectron && (
918 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
1019 <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
22 import { useEffect } from "react";
33
44 import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
5−import Sidebar from "../components/Sidebar";
5+import ThreadSidebar from "../components/Sidebar";
6+import { Sidebar, SidebarInset, SidebarProvider } from "../components/ui/sidebar";
67
78 function ChatRouteLayout() {
89 const navigate = useNavigate();
@@ -24,16 +25,23 @@ function ChatRouteLayout() { · 15 unmodified lines
2425 }, [navigate]);
2526
2627 return (
27− <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">
28− <Sidebar />
29− <DiffWorkerPoolProvider>
30− <Outlet />
31− </DiffWorkerPoolProvider>
32− </div>
28+ <SidebarProvider defaultOpen>
29+ <Sidebar
30+ side="left"
31+ collapsible="offcanvas"
32+ className="border-r border-border bg-card text-foreground"
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>
3342 );
3443 }
3544
3645 export const Route = createFileRoute("/_chat")({
3746 component: ChatRouteLayout,
3847 });
39−