From 24801d8d23075c47978e4d1092a3dafac2c7432f Mon Sep 17 00:00:00 2001 From: Robert Molina Date: Sun, 29 Mar 2026 19:44:25 -0400 Subject: [PATCH 1/2] feat: persist desktop sidebar state in app preferences --- .../_components/app-sidebar-provider.tsx | 58 ++++++++++ .../_components/chat-sidebar-provider.tsx | 58 ++++++++++ .../src/app/(app)/@chatSidebar/default.tsx | 11 +- apps/web/src/app/(app)/layout.tsx | 16 +-- .../preferences/app-preferences-provider.tsx | 26 ++++- apps/web/src/components/ui/sidebar.tsx | 100 +++++++++++++----- apps/web/src/preferences/app-preferences.ts | 58 ++++++++++ 7 files changed, 289 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-provider.tsx create mode 100644 apps/web/src/app/(app)/@chatSidebar/_components/chat-sidebar-provider.tsx diff --git a/apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-provider.tsx b/apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-provider.tsx new file mode 100644 index 00000000..4ce0cfdb --- /dev/null +++ b/apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-provider.tsx @@ -0,0 +1,58 @@ +"use client"; + +import * as React from "react"; +import { useAppPreferences } from "~/components/preferences/app-preferences-provider"; +import { SidebarProvider } from "~/components/ui/sidebar"; +import { useIsMobile } from "~/hooks/use-mobile"; + +type AppSidebarProviderProps = Omit< + React.ComponentProps, + "open" | "onOpenChange" | "initialWidthRem" | "onResizeEnd" +>; + +export function AppSidebarProvider({ + children, + ...props +}: AppSidebarProviderProps) { + const { preferences, updatePreferences } = useAppPreferences(); + const isMobile = useIsMobile(); + const [open, setOpen] = React.useState(() => preferences.appSidebarOpen); + + const handleOpenChange = React.useCallback( + (nextOpen: boolean) => { + if (open === nextOpen) { + return; + } + + if (!isMobile) { + updatePreferences({ appSidebarOpen: nextOpen }); + } + + setOpen(nextOpen); + }, + [open, isMobile, updatePreferences], + ); + + const handleResizeEnd = React.useCallback( + (widthRem: number) => { + if (isMobile) { + return; + } + + updatePreferences({ appSidebarWidthRem: widthRem }); + }, + [isMobile, updatePreferences], + ); + + return ( + + {children} + + ); +} diff --git a/apps/web/src/app/(app)/@chatSidebar/_components/chat-sidebar-provider.tsx b/apps/web/src/app/(app)/@chatSidebar/_components/chat-sidebar-provider.tsx new file mode 100644 index 00000000..05e5a10f --- /dev/null +++ b/apps/web/src/app/(app)/@chatSidebar/_components/chat-sidebar-provider.tsx @@ -0,0 +1,58 @@ +"use client"; + +import * as React from "react"; +import { useAppPreferences } from "~/components/preferences/app-preferences-provider"; +import { SidebarProvider } from "~/components/ui/sidebar"; +import { useIsMobile } from "~/hooks/use-mobile"; + +type ChatSidebarProviderProps = Omit< + React.ComponentProps, + "open" | "onOpenChange" | "initialWidthRem" | "onResizeEnd" +>; + +export function ChatSidebarProvider({ + children, + ...props +}: ChatSidebarProviderProps) { + const { preferences, updatePreferences } = useAppPreferences(); + const isMobile = useIsMobile(); + const [open, setOpen] = React.useState(() => preferences.chatSidebarOpen); + + const handleOpenChange = React.useCallback( + (nextOpen: boolean) => { + if (open === nextOpen) { + return; + } + + if (!isMobile) { + updatePreferences({ chatSidebarOpen: nextOpen }); + } + + setOpen(nextOpen); + }, + [isMobile, updatePreferences, open], + ); + + const handleResizeEnd = React.useCallback( + (widthRem: number) => { + if (isMobile) { + return; + } + + updatePreferences({ chatSidebarWidthRem: widthRem }); + }, + [isMobile, updatePreferences], + ); + + return ( + + {children} + + ); +} diff --git a/apps/web/src/app/(app)/@chatSidebar/default.tsx b/apps/web/src/app/(app)/@chatSidebar/default.tsx index 114c71b9..693a20cf 100644 --- a/apps/web/src/app/(app)/@chatSidebar/default.tsx +++ b/apps/web/src/app/(app)/@chatSidebar/default.tsx @@ -14,10 +14,15 @@ import { ThreadMessages } from "~/components/assistant-ui/thread-messages"; import { ComposerSources } from "~/components/assistant-ui/thread-sources"; import { ThreadWelcome } from "~/components/assistant-ui/thread-welcome"; import { Sidebar, SidebarContent } from "~/components/ui/sidebar"; +import { + CHAT_SIDEBAR_WIDTH_REM_DEFAULT, + CHAT_SIDEBAR_WIDTH_REM_MAX, + CHAT_SIDEBAR_WIDTH_REM_MIN, +} from "~/preferences/app-preferences"; -const CHAT_SIDEBAR_DEFAULT_WIDTH = "20rem"; -const CHAT_SIDEBAR_MIN_WIDTH = "20rem"; -const CHAT_SIDEBAR_WIDTH_MAX = "50rem"; +const CHAT_SIDEBAR_DEFAULT_WIDTH = `${CHAT_SIDEBAR_WIDTH_REM_DEFAULT}rem`; +const CHAT_SIDEBAR_MIN_WIDTH = `${CHAT_SIDEBAR_WIDTH_REM_MIN}rem`; +const CHAT_SIDEBAR_WIDTH_MAX = `${CHAT_SIDEBAR_WIDTH_REM_MAX}rem`; const CHAT_THREAD_MIN_WIDTH = "18rem"; // Leave 2rem for the padding. const CHAT_THREAD_MAX_WIDTH = "50rem"; const CHAT_SIDEBAR_REASONING_CONTENT_ID = diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index fe37af8d..3a709bb0 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -1,11 +1,13 @@ import { withAuth } from "~/app/_guards/page-guards"; -import { SidebarInset, SidebarProvider } from "~/components/ui/sidebar"; +import { SidebarInset } from "~/components/ui/sidebar"; import { Toaster } from "~/components/ui/toast"; import { getAppPreferences } from "~/preferences/get-preferences"; -import { AppLayoutProvider } from "../_components/app-layout-provider"; -import ChatSidebarTrigger from "./@chatSidebar/_components/chat-sidebar-trigger"; import "./styles.css"; +import { AppLayoutProvider } from "../_components/app-layout-provider"; import { AppProviders } from "../_components/app-providers"; +import { AppSidebarProvider } from "./@appSidebar/_components/app-sidebar-provider"; +import { ChatSidebarProvider } from "./@chatSidebar/_components/chat-sidebar-provider"; +import ChatSidebarTrigger from "./@chatSidebar/_components/chat-sidebar-trigger"; import { AppContainer } from "./_components/app-container"; import { AppProgressBar } from "./_components/app-progress-bar"; @@ -32,9 +34,9 @@ async function AppLayout({ return ( - +
- + {appSidebar} @@ -45,11 +47,11 @@ async function AppLayout({
{chatDrawer}
-
+ {chatSidebar}
-
+ {subscriptionModal}
diff --git a/apps/web/src/components/preferences/app-preferences-provider.tsx b/apps/web/src/components/preferences/app-preferences-provider.tsx index 2bd88e5f..0a84e206 100644 --- a/apps/web/src/components/preferences/app-preferences-provider.tsx +++ b/apps/web/src/components/preferences/app-preferences-provider.tsx @@ -9,6 +9,20 @@ import { } from "~/preferences/app-preferences"; import { setAppPreferencesAction } from "./app-preferences.actions"; +function isAbortOrNetworkError(error: unknown) { + if (error instanceof DOMException && error.name === "AbortError") { + return true; + } + + if (error instanceof TypeError) { + return /(networkerror|failed to fetch|load failed|fetch resource)/i.test( + error.message, + ); + } + + return false; +} + type AppPreferencesContextValue = { preferences: AppPreferences; setPreferences: ( @@ -35,8 +49,16 @@ export function AppPreferencesProvider({ [initialPreferences], ); const [preferences, savePreferences] = useOptimisticActionState( - async (_current: AppPreferences, next: AppPreferences) => - setAppPreferencesAction(next), + async (current: AppPreferences, next: AppPreferences) => { + try { + return await setAppPreferencesAction(next); + } catch (error) { + if (!isAbortOrNetworkError(error)) { + console.error("Failed to persist app preferences.", error); + } + return current; + } + }, initialState, ( current: AppPreferences, diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index c1339be7..14e7b72d 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -24,8 +24,6 @@ import { import { useIsMobile } from "~/hooks/use-mobile"; import { cn } from "~/lib/cn"; -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; -const SIDEBAR_COOKIE_NAME = "sidebar:state"; const SIDEBAR_KEYBOARD_SHORTCUT_OPTION_S = "KeyS"; const SIDEBAR_KEYBOARD_SHORTCUT_CTRL_SHIFT_B = "KeyB"; const SIDEBAR_MIN_WIDTH = "14rem"; @@ -35,6 +33,19 @@ const SIDEBAR_WIDTH_MAX = "50rem"; const SIDEBAR_WIDTH_MOBILE = "18rem"; const SIDEBAR_DRAG_HANDLE_SPACING = 16; +function parseRem(value: `${string}rem`) { + const parsed = Number.parseFloat(value); + return parsed; +} + +function clampNumber(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +function roundNumber(value: number) { + return Math.round(value * 1000) / 1000; +} + type SidebarContextProps = { state: "expanded" | "collapsed"; open: boolean; @@ -45,6 +56,8 @@ type SidebarContextProps = { toggleSidebar: () => void; isDraggingRail: boolean; setIsDraggingRail: (isDraggingRail: boolean) => void; + initialWidthRem?: number; + handleResizeEnd: (widthRem: number) => void; }; const SidebarContext = React.createContext(null); @@ -61,12 +74,16 @@ type SidebarProviderProps = React.ComponentProps<"div"> & { defaultOpen?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void; + initialWidthRem?: number; + onResizeEnd?: (widthRem: number) => void; }; function SidebarProvider({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, + initialWidthRem, + onResizeEnd, className, style, children, @@ -81,6 +98,14 @@ function SidebarProvider({ // We use openProp and setOpenProp for control from outside the component. const [_open, _setOpen] = React.useState(defaultOpen); const open = openProp ?? _open; + + const handleResizeEnd = React.useCallback( + (widthRem: number) => { + onResizeEnd?.(widthRem); + }, + [onResizeEnd], + ); + const setOpen = React.useCallback( (value: boolean | ((value: boolean) => boolean)) => { const openState = typeof value === "function" ? value(open) : value; @@ -89,10 +114,6 @@ function SidebarProvider({ } else { _setOpen(openState); } - - // This sets the cookie to keep the sidebar state. - // biome-ignore lint/suspicious/noDocumentCookie: - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; }, [setOpenProp, open], ); @@ -147,6 +168,8 @@ function SidebarProvider({ const contextValue = React.useMemo( () => ({ + handleResizeEnd, + initialWidthRem, isDraggingRail, isMobile, open, @@ -157,7 +180,17 @@ function SidebarProvider({ state, toggleSidebar, }), - [state, open, setOpen, isMobile, openMobile, toggleSidebar, isDraggingRail], + [ + isMobile, + initialWidthRem, + handleResizeEnd, + open, + openMobile, + isDraggingRail, + setOpen, + state, + toggleSidebar, + ], ); return ( @@ -201,9 +234,9 @@ function Sidebar({ side?: "left" | "right"; variant?: "sidebar" | "floating" | "inset"; collapsible?: "offcanvas" | "icon" | "none"; - defaultWidth?: string; - minWidth?: string; - maxWidth?: string; + defaultWidth?: `${string}rem`; + minWidth?: `${string}rem`; + maxWidth?: `${string}rem`; defaultOpen?: boolean; }) { const { @@ -214,10 +247,20 @@ function Sidebar({ setIsDraggingRail, open, setOpen, + initialWidthRem, + handleResizeEnd, } = useSidebar(); // Local state for this sidebar instance - const [width, setWidth] = React.useState(defaultWidth); + const [widthRem, setWidthRem] = React.useState(() => { + const defaultWidthRem = defaultWidth + ? parseRem(defaultWidth) + : parseRem(SIDEBAR_WIDTH); + + return typeof initialWidthRem === "number" + ? initialWidthRem + : defaultWidthRem; + }); const toggleSidebar = React.useCallback(() => { return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); @@ -231,11 +274,11 @@ function Sidebar({ e.preventDefault(); e.stopPropagation(); + const minWidthRem = parseRem(minWidth); + const maxWidthRem = parseRem(maxWidth); const startX = e.clientX; - const startWidth = Number.parseFloat(width.replace("rem", "")); - const minWidthNum = Number.parseFloat(minWidth.replace("rem", "")); - const maxWidthNum = Number.parseFloat(maxWidth.replace("rem", "")); - + const startWidthRem = widthRem; + let latestWidthRem = widthRem; let isDragging = false; const handleMouseMove = (moveEvent: globalThis.MouseEvent) => { @@ -246,20 +289,22 @@ function Sidebar({ const deltaX = moveEvent.clientX - startX; const factor = side === "left" ? 1 : -1; - const newWidthRem = startWidth + (deltaX * factor) / 16; + const newWidthRem = startWidthRem + (deltaX * factor) / 16; // Apply normal min/max constraints - const clampedWidth = Math.max( - minWidthNum, - Math.min(maxWidthNum, newWidthRem), + const clampedWidthRem = clampNumber( + newWidthRem, + minWidthRem, + maxWidthRem, ); // Auto-collapse if dragged below minimum threshold - if (newWidthRem < minWidthNum * 0.8 && collapsible !== "none") { + if (newWidthRem < minWidthRem * 0.8 && collapsible !== "none") { setOpen(false); } else { setOpen(true); - setWidth(`${clampedWidth}rem`); + latestWidthRem = clampedWidthRem; + setWidthRem(clampedWidthRem); } }; @@ -269,6 +314,8 @@ function Sidebar({ if (collapsible !== "none") { toggleSidebar(); } + } else { + handleResizeEnd(roundNumber(latestWidthRem)); } // Clean up @@ -281,14 +328,15 @@ function Sidebar({ document.addEventListener("mouseup", handleMouseUp); }, [ - width, - minWidth, - maxWidth, + widthRem, side, collapsible, toggleSidebar, setOpen, setIsDraggingRail, + handleResizeEnd, + maxWidth, + minWidth, ], ); @@ -298,7 +346,7 @@ function Sidebar({ data-slot="sidebar" style={ { - "--sidebar-width": width, + "--sidebar-width": `${widthRem}rem`, } as React.CSSProperties } className={cn( @@ -371,7 +419,7 @@ function Sidebar({ ref={ref} style={ { - "--sidebar-width": state === "collapsed" ? 0 : width, + "--sidebar-width": state === "collapsed" ? 0 : `${widthRem}rem`, } as React.CSSProperties } > diff --git a/apps/web/src/preferences/app-preferences.ts b/apps/web/src/preferences/app-preferences.ts index 0ebc2999..a42cd55b 100644 --- a/apps/web/src/preferences/app-preferences.ts +++ b/apps/web/src/preferences/app-preferences.ts @@ -1,13 +1,29 @@ export const APP_PREFERENCES_COOKIE_NAME = "journl:preferences"; export const APP_PREFERENCES_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; +export const APP_SIDEBAR_WIDTH_REM_DEFAULT = 14; +export const APP_SIDEBAR_WIDTH_REM_MIN = 14; +export const APP_SIDEBAR_WIDTH_REM_MAX = 50; + +export const CHAT_SIDEBAR_WIDTH_REM_DEFAULT = 20; +export const CHAT_SIDEBAR_WIDTH_REM_MIN = 20; +export const CHAT_SIDEBAR_WIDTH_REM_MAX = 50; + export type JournalTimelineView = "timeline" | "entries"; export type AppPreferences = { journalTimelineView: JournalTimelineView; + appSidebarOpen: boolean; + appSidebarWidthRem: number; + chatSidebarOpen: boolean; + chatSidebarWidthRem: number; }; export const DEFAULT_APP_PREFERENCES: AppPreferences = { + appSidebarOpen: true, + appSidebarWidthRem: APP_SIDEBAR_WIDTH_REM_DEFAULT, + chatSidebarOpen: true, + chatSidebarWidthRem: CHAT_SIDEBAR_WIDTH_REM_DEFAULT, journalTimelineView: "entries", }; @@ -16,12 +32,54 @@ const JOURNAL_TIMELINE_VIEWS = new Set([ "entries", ]); +function normalizeSidebarWidth( + value: number | string | null | undefined, + min: number, + max: number, + fallback: number, +) { + const resolved = + typeof value === "number" + ? value + : typeof value === "string" + ? Number.parseFloat(value) + : Number.NaN; + + if (!Number.isFinite(resolved)) { + return fallback; + } + + return Math.min(max, Math.max(min, resolved)); +} + export function normalizeAppPreferences( input?: Partial | null, ): AppPreferences { const journalTimelineView = input?.journalTimelineView; + const appSidebarOpen = input?.appSidebarOpen; + const chatSidebarOpen = input?.chatSidebarOpen; return { + appSidebarOpen: + typeof appSidebarOpen === "boolean" + ? appSidebarOpen + : DEFAULT_APP_PREFERENCES.appSidebarOpen, + appSidebarWidthRem: normalizeSidebarWidth( + input?.appSidebarWidthRem, + APP_SIDEBAR_WIDTH_REM_MIN, + APP_SIDEBAR_WIDTH_REM_MAX, + DEFAULT_APP_PREFERENCES.appSidebarWidthRem, + ), + chatSidebarOpen: + typeof chatSidebarOpen === "boolean" + ? chatSidebarOpen + : DEFAULT_APP_PREFERENCES.chatSidebarOpen, + chatSidebarWidthRem: normalizeSidebarWidth( + input?.chatSidebarWidthRem, + CHAT_SIDEBAR_WIDTH_REM_MIN, + CHAT_SIDEBAR_WIDTH_REM_MAX, + DEFAULT_APP_PREFERENCES.chatSidebarWidthRem, + ), journalTimelineView: journalTimelineView && JOURNAL_TIMELINE_VIEWS.has(journalTimelineView) ? journalTimelineView From 2ad01f106dccabf39029b98a769fc8948dbf2067 Mon Sep 17 00:00:00 2001 From: Robert Molina Date: Sun, 29 Mar 2026 19:47:01 -0400 Subject: [PATCH 2/2] chore: updated biome config --- apps/drizzle-studio/biome.json | 2 +- apps/stripe/biome.json | 2 +- apps/web/biome.json | 2 +- apps/web/src/app/(app)/pages/_components/page-shell.tsx | 2 +- biome.json | 2 +- packages/auth/biome.json | 2 +- packages/blocknote/biome.json | 2 +- packages/db/biome.json | 2 +- tooling/github/biome.json | 2 +- tooling/tsconfig/biome.json | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/drizzle-studio/biome.json b/apps/drizzle-studio/biome.json index d443fd38..40b7f9f4 100644 --- a/apps/drizzle-studio/biome.json +++ b/apps/drizzle-studio/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", "extends": "//", "root": false } diff --git a/apps/stripe/biome.json b/apps/stripe/biome.json index d443fd38..40b7f9f4 100644 --- a/apps/stripe/biome.json +++ b/apps/stripe/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", "extends": "//", "root": false } diff --git a/apps/web/biome.json b/apps/web/biome.json index 5d247e10..76116a8e 100644 --- a/apps/web/biome.json +++ b/apps/web/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", "css": { "parser": { "tailwindDirectives": true diff --git a/apps/web/src/app/(app)/pages/_components/page-shell.tsx b/apps/web/src/app/(app)/pages/_components/page-shell.tsx index 3f16beb7..f7ab0d4c 100644 --- a/apps/web/src/app/(app)/pages/_components/page-shell.tsx +++ b/apps/web/src/app/(app)/pages/_components/page-shell.tsx @@ -10,7 +10,7 @@ type PageShellProps = React.ComponentProps<"div"> & { page: Pick; }; -export async function PageShell({ +export function PageShell({ className, children, page, diff --git a/biome.json b/biome.json index 1f23f6a3..2dd90904 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", "assist": { "actions": { "source": { diff --git a/packages/auth/biome.json b/packages/auth/biome.json index d443fd38..40b7f9f4 100644 --- a/packages/auth/biome.json +++ b/packages/auth/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", "extends": "//", "root": false } diff --git a/packages/blocknote/biome.json b/packages/blocknote/biome.json index d443fd38..40b7f9f4 100644 --- a/packages/blocknote/biome.json +++ b/packages/blocknote/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", "extends": "//", "root": false } diff --git a/packages/db/biome.json b/packages/db/biome.json index d443fd38..40b7f9f4 100644 --- a/packages/db/biome.json +++ b/packages/db/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", "extends": "//", "root": false } diff --git a/tooling/github/biome.json b/tooling/github/biome.json index d443fd38..40b7f9f4 100644 --- a/tooling/github/biome.json +++ b/tooling/github/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", "extends": "//", "root": false } diff --git a/tooling/tsconfig/biome.json b/tooling/tsconfig/biome.json index d443fd38..40b7f9f4 100644 --- a/tooling/tsconfig/biome.json +++ b/tooling/tsconfig/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", "extends": "//", "root": false }