{projects.length === 0 ? (
<>
-
No projects yet
+
No projects yet
>
- ) : scopedProjectGroup ? (
+ ) : compact ? null : scopedProjectGroup ? (
`No threads in ${scopedProjectGroup.displayName} yet`
) : (
"No threads yet"
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index c9700496f420..d48f1781172e 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -501,6 +501,9 @@ export function useSettingsRestore(onRestored?: () => void) {
...(theme !== "system" ? ["Theme"] : []),
...(!followSystem ? ["Follow system"] : []),
...(themeHalves !== null ? ["Theme mix"] : []),
+ ...(settings.compactSidebarEnabled !== DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled
+ ? ["Compact sidebar"]
+ : []),
...(settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast
? ["Contrast"]
: []),
@@ -605,6 +608,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.browserLinkTarget,
settings.browserAutoShowFloatingPreview,
settings.appearanceContrast,
+ settings.compactSidebarEnabled,
settings.diffColorScheme,
settings.enableAgentBrowserAccess,
settings.confirmQuit,
@@ -712,6 +716,7 @@ export function useSettingsRestore(onRestored?: () => void) {
}
updateSettings({
appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
+ compactSidebarEnabled: DEFAULT_UNIFIED_SETTINGS.compactSidebarEnabled,
diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,
timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode,
@@ -1357,6 +1362,22 @@ export function AppearanceSettingsPanel() {
+
+
+
+ updateSettings({ compactSidebarEnabled: Boolean(checked) })
+ }
+ aria-label="Compact sidebar"
+ />
+ }
+ />
+
);
}
diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx
index c29068934efa..c206174a397a 100644
--- a/apps/web/src/components/settings/SettingsSidebarNav.tsx
+++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx
@@ -24,6 +24,7 @@ import {
XIcon,
} from "lucide-react";
import { useLocation, useNavigate } from "@tanstack/react-router";
+import { useCompactSidebarEnabled } from "../../hooks/useSettings";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
@@ -111,12 +112,13 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
(item) => item.to !== "/settings/projects" || isSettingsOverviewVisible(scopeSearch),
);
const { isMobile, setOpenMobile, open, setOpen } = useSidebar();
+ const compactSidebarEnabled = useCompactSidebarEnabled();
const searchInputRef = useRef
(null);
const [query, setQuery] = useState("");
const [activeResultIndex, setActiveResultIndex] = useState(0);
const searchableItems = useAvailableSettingsSearchItems();
const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]);
- const isSearching = query.trim().length > 0;
+ const isSearching = query.trim().length > 0 && !(compactSidebarEnabled && !isMobile && !open);
const hasResults = results.length > 0;
useEffect(() => {
@@ -233,7 +235,18 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
<>
-
+
{
+ setOpen(true);
+ requestAnimationFrame(() => searchInputRef.current?.focus());
+ }}
+ >
+
+
+
handleSectionClick(item.to)}
>
- {item.label}
+
+ {item.label}
+
);
@@ -343,10 +360,12 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
-
-
-
-
+
+
+
+
+
+
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index 9a8af52b90f5..1bef4848e764 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -159,6 +159,12 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Panel animations",
to: "/settings/appearance",
},
+ {
+ id: "compact-sidebar",
+ title: "Compact sidebar",
+ to: "/settings/appearance",
+ searchTerms: ["advanced collapsed icons rail hover navigation"],
+ },
{
id: "environment-identification",
title: "Environment identification",
diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx
index afbbf7671dfc..2f65cf8c3697 100644
--- a/apps/web/src/components/sidebar/SidebarChrome.tsx
+++ b/apps/web/src/components/sidebar/SidebarChrome.tsx
@@ -86,7 +86,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
+
{currentFooterPage ? (
-
+
- Back
+ Back
) : (
@@ -224,8 +224,10 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
return (
-
-
+
+
+
+
);
diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx
index 878235615b39..d0718d9856f8 100644
--- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx
+++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx
@@ -20,9 +20,10 @@ import {
} from "react";
import { cn } from "~/lib/utils";
+import { useCompactSidebarEnabled } from "../../hooks/useSettings";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
-import { SidebarMenuButton } from "../ui/sidebar";
+import { SidebarMenuButton, useSidebar } from "../ui/sidebar";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
export interface SidebarThreadHeaderProps {
@@ -69,6 +70,9 @@ export function SidebarThreadHeader({
activeSearchResultIndex,
onClearSearch,
}: SidebarThreadHeaderProps) {
+ const compactEnabled = useCompactSidebarEnabled();
+ const { state, isMobile, setOpen } = useSidebar();
+ const compact = compactEnabled && state === "collapsed" && !isMobile;
const resultsVisible = isSearching && searchResultCount > 0;
// Results shrink as the query narrows, so the active index can outrun the
// list; pointing aria-activedescendant at a removed option strands the
@@ -79,10 +83,24 @@ export function SidebarThreadHeader({
: "New thread";
return (
-
+
+ {compact ? (
+
{
+ setOpen(true);
+ requestAnimationFrame(() => searchInputRef.current?.focus());
+ }}
+ >
+
+
+ ) : null}
{/* Segmented well: the icons read as one control instead of three loose
buttons competing with the search field beside them. */}
-
+
{hasProjects ? (
<>
{projectScope}
diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
index a94b7801ecfd..8c04eec6fe7d 100644
--- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
+++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
@@ -348,7 +348,7 @@ function SidebarUpdateControl() {
);
return (
-
+
{
diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts
index 194cc36c55f4..fa4b8bc8fd31 100644
--- a/apps/web/src/hooks/useSettings.ts
+++ b/apps/web/src/hooks/useSettings.ts
@@ -379,6 +379,13 @@ export function useLegacySidebarEnabled(): boolean {
return settingsHydrated && legacySidebarEnabled;
}
+/** Keep the default collapsed sidebar until persisted client settings hydrate. */
+export function useCompactSidebarEnabled(): boolean {
+ const settingsHydrated = useClientSettingsHydrated();
+ const compactSidebarEnabled = useClientSettingsValue().compactSidebarEnabled;
+ return settingsHydrated && compactSidebarEnabled;
+}
+
/** Read current settings for one environment, merged with client-local preferences. */
export function useEnvironmentSettings(
environmentId: EnvironmentId,
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 004a53b320e8..826440556d2c 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -436,6 +436,7 @@ export const ClientSettingsSchema = Schema.Struct({
// old keys, so everyone, including prior beta opt-outs, resets to the new
// default sidebar.
legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
+ compactSidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),
),
@@ -1490,6 +1491,7 @@ export const ClientSettingsPatch = Schema.Struct({
proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean),
showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
+ compactSidebarEnabled: Schema.optionalKey(Schema.Boolean),
sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
sidebarProjectGroupingOverrides: Schema.optionalKey(
Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),
From 8b9b82145b69361744b435a82bdc4d59c1b5a1ee Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Sun, 13 Sep 2026 04:39:45 +0000
Subject: [PATCH 02/11] fix(web): preserve compact sidebar history and drag
controls
---
apps/web/src/components/Sidebar.tsx | 32 +++++++++++++++++++++--------
1 file changed, 23 insertions(+), 9 deletions(-)
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 6dd26b9fb7cf..7be5533fc530 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -1631,7 +1631,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
data-thread-item
{...sortableRootProps}
{...(fileDropHandlers ?? {})}
- className="list-none py-0.5"
+ className={cn("list-none py-0.5", sortable?.isDragging && "relative z-20")}
>
0 ? (
-
+
+
+ }
+ >
+
+
+ Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more
+
+
+
+ Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more
+
+
) : null}
From 1bef2d6050b83fe0ced5fe33bb8b058d7a91570b Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Sun, 13 Sep 2026 04:47:17 +0000
Subject: [PATCH 03/11] fix(web): show compact sidebar drop action feedback
---
apps/web/src/components/Sidebar.tsx | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 7be5533fc530..13e6a9fec0cc 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -1322,6 +1322,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
[isRenaming, onStartRename, thread.title, threadRef],
);
const [isFileDragOver, setIsFileDragOver] = useState(false);
+ const [compactTooltipOpen, setCompactTooltipOpen] = useState(false);
const fileDropHandlers = useMemo(
() =>
onFileDropThreads
@@ -1633,7 +1634,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
{...(fileDropHandlers ?? {})}
className={cn("list-none py-0.5", sortable?.isDragging && "relative z-20")}
>
-
+
: null}
- {detailsTooltip}
+ {sortable?.isDragging ? (
+ {dragDestination}
+ ) : (
+ detailsTooltip
+ )}
);
From ceb21331d6e178722560153cf5cfcbb7fab47afa Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Sun, 13 Sep 2026 04:50:59 +0000
Subject: [PATCH 04/11] fix(web): preserve tooltips across sidebar modes
---
apps/web/src/components/Sidebar.tsx | 48 +++++++++++++++++++++++------
1 file changed, 39 insertions(+), 9 deletions(-)
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 13e6a9fec0cc..64fab8f7b82f 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -587,7 +587,18 @@ function SidebarSectionPlaceholder(props: {
props.isDropTarget && "border-primary/40 bg-primary/5 text-primary",
)}
>
- {props.label}
+ {props.label}
+ {props.marker === "settled-placeholder" ? (
+
+ ) : (
+
+ )}
) : null}
@@ -611,19 +622,30 @@ function SidebarDragBoundary(props: {
className="pointer-events-none relative mx-0.5 -mb-px h-0"
>
{props.visible ? (
-
+
- {props.label}
+ {props.label}
+ {props.marker === "pinned-header" ? (
+
+ ) : (
+
+ )}
@@ -1322,7 +1344,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
[isRenaming, onStartRename, thread.title, threadRef],
);
const [isFileDragOver, setIsFileDragOver] = useState(false);
- const [compactTooltipOpen, setCompactTooltipOpen] = useState(false);
+ const [tooltipOpen, setTooltipOpen] = useState(false);
const fileDropHandlers = useMemo(
() =>
onFileDropThreads
@@ -1635,8 +1657,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
className={cn("list-none py-0.5", sortable?.isDragging && "relative z-20")}
>
-
+
-
+
Date: Sun, 13 Sep 2026 05:20:32 +0000
Subject: [PATCH 05/11] fix(web): remove duplicate compact sidebar header inset
---
apps/web/src/workspaceTitlebar.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/src/workspaceTitlebar.ts b/apps/web/src/workspaceTitlebar.ts
index b481221e63aa..aed95897cc55 100644
--- a/apps/web/src/workspaceTitlebar.ts
+++ b/apps/web/src/workspaceTitlebar.ts
@@ -1,2 +1,2 @@
export const COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS =
- "[[data-sidebar-state=collapsed]_&]:pl-[var(--workspace-titlebar-content-left)]";
+ "[[data-sidebar-state=collapsed]_&]:pl-[var(--workspace-titlebar-content-left)] [[data-sidebar-state=collapsed]:has([data-side=left][data-collapsible=icon])_&]:pl-[max(calc(env(safe-area-inset-left)+1.25rem),calc(var(--workspace-titlebar-content-left)-var(--sidebar-width-icon)))]";
From cbdcb003a2a6d544152cddf0e2f60f0fb01867c3 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Sun, 13 Sep 2026 06:04:55 +0000
Subject: [PATCH 06/11] feat(web): add interactive compact sidebar setting
preview
---
.../settings/CompactSidebarPreview.tsx | 53 +++++++++++++++++++
.../components/settings/SettingsPanels.tsx | 28 ++++++----
.../src/components/settings/settingsSearch.ts | 2 +-
3 files changed, 71 insertions(+), 12 deletions(-)
create mode 100644 apps/web/src/components/settings/CompactSidebarPreview.tsx
diff --git a/apps/web/src/components/settings/CompactSidebarPreview.tsx b/apps/web/src/components/settings/CompactSidebarPreview.tsx
new file mode 100644
index 000000000000..49fb0754e015
--- /dev/null
+++ b/apps/web/src/components/settings/CompactSidebarPreview.tsx
@@ -0,0 +1,53 @@
+import { useState } from "react";
+
+import { cn } from "~/lib/utils";
+
+export function CompactSidebarPreview() {
+ const [collapsed, setCollapsed] = useState(false);
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index d48f1781172e..c9174e07e28c 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -160,6 +160,7 @@ import {
import { searchableSetting } from "./settingsSearch";
import { ProjectFavicon } from "../ProjectFavicon";
import { PanelAnimationsPreview } from "./PanelAnimationsPreview";
+import { CompactSidebarPreview } from "./CompactSidebarPreview";
const ENVIRONMENT_IDENTIFICATION_LABELS: Record = {
artwork: "Artwork",
@@ -1361,23 +1362,28 @@ export function AppearanceSettingsPanel() {
/>
-
-
-
+
+
+
);
}
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index 1bef4848e764..be62ad6b25b5 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -163,7 +163,7 @@ export const SETTINGS_SEARCH_ITEMS = [
id: "compact-sidebar",
title: "Compact sidebar",
to: "/settings/appearance",
- searchTerms: ["advanced collapsed icons rail hover navigation"],
+ searchTerms: ["collapsed icons rail hover navigation preview"],
},
{
id: "environment-identification",
From 0c0226576f276828ec42957c2579c1d83e402456 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Sun, 13 Sep 2026 06:32:08 +0000
Subject: [PATCH 07/11] fix(web): place compact sidebar preview beside toggle
---
apps/web/src/components/settings/SettingsPanels.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index c9174e07e28c..efefc79c32cd 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -1367,7 +1367,7 @@ export function AppearanceSettingsPanel() {
{...searchableSetting("compact-sidebar")}
description="Keep an icon rail when the sidebar is collapsed. Click the preview to try it."
control={
-
+
Date: Sun, 13 Sep 2026 07:40:08 +0000
Subject: [PATCH 08/11] fix(web): tighten compact rail and dock snoozed threads
---
apps/web/src/components/Sidebar.tsx | 141 +++++++++++++++---
.../sidebar/SidebarThreadHeader.tsx | 99 +++++++-----
apps/web/src/components/ui/sidebar.tsx | 3 +
3 files changed, 183 insertions(+), 60 deletions(-)
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 64fab8f7b82f..a5420ffb8a36 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -6,6 +6,7 @@ import { replaceComposerContextReferences } from "@t3tools/shared/composerContex
import * as Schema from "effect/Schema";
import {
DndContext,
+ DragOverlay,
useSensor,
useSensors,
type DragEndEvent,
@@ -73,6 +74,7 @@ import {
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
+import { createPortal } from "react-dom";
import { useParams, useRouter } from "@tanstack/react-router";
import { useRightPanelStore } from "../rightPanelStore";
@@ -506,7 +508,7 @@ function SnoozePopoverButton(props: {
type SortableThreadRowBag = Pick<
ReturnType,
"listeners" | "setNodeRef" | "transform" | "transition" | "isDragging"
->;
+> & { hidden?: boolean };
function SortableThreadRow(props: {
id: string;
@@ -793,7 +795,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
);
if (compact) {
return (
-
+
@@ -1507,7 +1509,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
// A zero-height boundary also makes dnd-kit scale the source to
// zero. Only projected peers use scaleY as a visibility sentinel.
visibility:
- !sortable.isDragging && sortable.transform?.scaleY === 0
+ sortable.hidden || (!sortable.isDragging && sortable.transform?.scaleY === 0)
? ("hidden" as const)
: undefined,
},
@@ -1654,10 +1656,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
data-thread-item
{...sortableRootProps}
{...(fileDropHandlers ?? {})}
- className={cn("list-none py-0.5", sortable?.isDragging && "relative z-20")}
+ className={cn("list-none", sortable?.isDragging && "relative z-20")}
>
(null);
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
@@ -2867,6 +2872,7 @@ export default function Sidebar() {
[setSettledShelfExpanded],
);
const renderedSettledThreads = useMemo(() => {
+ if (compact) return EMPTY_THREADS;
if (settledShelfExpanded) return visibleSettledThreads;
if (routeThreadKey === null) return EMPTY_THREADS;
const routeThread = visibleSettledThreads.find(
@@ -2874,7 +2880,7 @@ export default function Sidebar() {
scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
);
return routeThread === undefined ? EMPTY_THREADS : [routeThread];
- }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]);
+ }, [compact, routeThreadKey, settledShelfExpanded, visibleSettledThreads]);
// The snoozed shelf is collapsed by default: out of the way, never gone.
// Collapsed threads don't render (and so don't participate in jump
@@ -3271,8 +3277,18 @@ export default function Sidebar() {
const threadListRef = useRef(null);
const dragLabelOffsetRef = useRef(0);
const restrictBelowPins = useCallback(
- (args) => restrictBelowSidebarLabel(args, dragLabelOffsetRef.current),
- [],
+ (args) =>
+ restrictBelowSidebarLabel(
+ {
+ ...args,
+ // The fixed snoozed shelf shares the main list's drag boundary.
+ containerNodeRect: compact
+ ? (threadListRef.current?.getBoundingClientRect() ?? args.containerNodeRect)
+ : args.containerNodeRect,
+ },
+ dragLabelOffsetRef.current,
+ ),
+ [compact],
);
const listMotionRef = useRef | null>(null);
const attachListMotionRef = useCallback((node: HTMLUListElement | null) => {
@@ -3494,7 +3510,7 @@ export default function Sidebar() {
pinnedThreads.length +
activeThreads.length +
snoozedThreads.length +
- settledThreads.length ===
+ (compact ? 0 : settledThreads.length) ===
0
) {
return [];
@@ -3510,13 +3526,15 @@ export default function Sidebar() {
items.push({ kind: "marker", marker: "snoozed-header" });
items.push(...rowsOf(visibleSnoozedThreads, "snoozed"));
}
- items.push({ kind: "marker", marker: "settled-header" });
- const settledRows = rowsOf(renderedSettledThreads, "settled");
- items.push({ kind: "marker", marker: "settled-placeholder" });
- items.push(...settledRows);
+ if (!compact) {
+ items.push({ kind: "marker", marker: "settled-header" });
+ items.push({ kind: "marker", marker: "settled-placeholder" });
+ items.push(...rowsOf(renderedSettledThreads, "settled"));
+ }
return items;
}, [
activeThreads,
+ compact,
pinnedThreads,
renderedSettledThreads,
settledThreads.length,
@@ -3602,6 +3620,25 @@ export default function Sidebar() {
snoozedThreads.length,
],
);
+ const draggingCompactSnoozed = compact && dragState?.activeSection === "snoozed";
+ const compactSnoozedDragThread =
+ draggingCompactSnoozed && dragState ? threadByKey.get(dragState.activeKey) : undefined;
+ const compactSidebarSortingStrategy = useCallback(
+ (args) => {
+ const item = sidebarListItems[args.index];
+ // Footer rows stay anchored while the main list previews a reorder.
+ if (
+ draggingCompactSnoozed ||
+ (item?.kind === "thread"
+ ? item.section === "snoozed"
+ : item?.marker === "snoozed-header")
+ ) {
+ return null;
+ }
+ return sidebarSortingStrategy(args);
+ },
+ [draggingCompactSnoozed, sidebarListItems, sidebarSortingStrategy],
+ );
// Hidden and filtered threads keep their keys. Reserve those slots without
// including the rows in the visible drop order or writing to them.
const { pinnedKeysById, activeKeysById } = useMemo(
@@ -4506,6 +4543,15 @@ export default function Sidebar() {
0 ? (
+
+ ) : null
+ }
fixedHeader={
// Lifted above the stage backdrop, whose fade bleeds below the
// header and would otherwise paint across the search row's outline.
@@ -4747,14 +4793,17 @@ export default function Sidebar() {
modifiers={[
restrictToVerticalAxis,
restrictBelowPins,
- restrictToFirstScrollableAncestor,
+ ...(compact ? [] : [restrictToFirstScrollableAncestor]),
]}
onDragStart={handleThreadDragStart}
onDragOver={handleThreadDragOver}
onDragEnd={handleThreadDragEnd}
>
-
+
- {(bag) => renderThreadRowInner(thread, section, bag)}
+ {(bag) =>
+ renderThreadRowInner(
+ thread,
+ section,
+ draggingCompactSnoozed && bag.isDragging
+ ? { ...bag, hidden: true }
+ : bag,
+ )
+ }
);
};
const from = dragState?.activeSection ?? null;
+ const snoozedItems: ReactNode[] = [];
const items: ReactNode[] = [
,
];
for (const item of sidebarListItems) {
+ const destination =
+ compact &&
+ (item.kind === "thread"
+ ? item.section === "snoozed"
+ : item.marker === "snoozed-header")
+ ? snoozedItems
+ : items;
if (item.kind === "thread") {
- items.push(renderThreadRow(threadByKey.get(item.key)!, item.section));
+ destination.push(
+ renderThreadRow(threadByKey.get(item.key)!, item.section),
+ );
continue;
}
switch (item.marker) {
@@ -4946,7 +5013,7 @@ export default function Sidebar() {
);
break;
case "snoozed-header":
- items.push(
+ destination.push(
+ {compactSnoozedDragThread ? (
+
+ {renderThreadRowInner(
+ compactSnoozedDragThread,
+ "snoozed",
+ {
+ isDragging: true,
+ listeners: undefined,
+ setNodeRef: () => {},
+ transform: null,
+ transition: undefined,
+ },
+ )}
+
+ ) : null}
+ ,
+ document.body,
+ "compact-snoozed-drag",
+ )
+ : null,
+ ];
})()}
- {settledShelfExpanded && hiddenSettledCount > 0 ? (
+ {!compact && settledShelfExpanded && hiddenSettledCount > 0 ? (
-
0;
// Results shrink as the query narrows, so the active index can outrun the
// list; pointing aria-activedescendant at a removed option strands the
@@ -82,12 +85,13 @@ export function SidebarThreadHeader({
? `New thread (${newThreadShortcutLabel})`
: "New thread";
- return (
-
+ const actions = (
+
{compact ? (
{
+ setActionsOpen(false);
setOpen(true);
requestAnimationFrame(() => searchInputRef.current?.focus());
}}
@@ -95,6 +99,48 @@ export function SidebarThreadHeader({
) : null}
+ {hasProjects ? (
+ <>
+ {projectScope}
+ {
+ setActionsOpen(false);
+ onNewProject();
+ }}
+ >
+
+
+ >
+ ) : null}
+
+ {newThreadLabel}
+
+ New thread in current project: Shift+click
+ {newThreadInProjectShortcutLabel ? ` (${newThreadInProjectShortcutLabel})` : ""}
+
+
+ ) : (
+ newThreadLabel
+ )
+ }
+ disabled={newThreadDisabled}
+ onClick={(event) => {
+ setActionsOpen(false);
+ onNewThread(event);
+ }}
+ >
+
+
+
+ );
+
+ return (
+
{/* Segmented well: the icons read as one control instead of three loose
buttons competing with the search field beside them. */}
-
- {hasProjects ? (
- <>
- {projectScope}
-
-
-
- >
- ) : null}
-
- {newThreadLabel}
-
- New thread in current project: Shift+click
- {newThreadInProjectShortcutLabel ? ` (${newThreadInProjectShortcutLabel})` : ""}
-
-
- ) : (
- newThreadLabel
- )
- }
- disabled={newThreadDisabled}
- onClick={onNewThread}
- >
-
-
-
+ {compact ? (
+
+ }>
+
+
+
+ {actions}
+
+
+ ) : (
+ actions
+ )}
);
}
diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx
index a7abcb3bb550..c8938497cc04 100644
--- a/apps/web/src/components/ui/sidebar.tsx
+++ b/apps/web/src/components/ui/sidebar.tsx
@@ -690,9 +690,11 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps
& {
fixedHeader?: React.ReactNode;
+ fixedFooter?: React.ReactNode;
}) {
return (
<>
@@ -716,6 +718,7 @@ function SidebarContent({
{...props}
/>
+ {fixedFooter ? {fixedFooter}
: null}
>
);
}
From f752944d906d4201b8f272fbe11646db1e3fb030 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Sun, 13 Sep 2026 07:49:56 +0000
Subject: [PATCH 09/11] fix(web): preserve compact drag geometry
---
apps/web/src/components/Sidebar.drag.ts | 14 ++++++---
apps/web/src/components/Sidebar.tsx | 42 ++++++++++++-------------
2 files changed, 30 insertions(+), 26 deletions(-)
diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts
index 0aef9dec14b1..c64de1c27b75 100644
--- a/apps/web/src/components/Sidebar.drag.ts
+++ b/apps/web/src/components/Sidebar.drag.ts
@@ -103,6 +103,7 @@ export function createSidebarSortingStrategy(input: {
snoozedThreadCount?: number;
cardHeight?: number;
slimHeight?: number;
+ compact?: boolean;
/** Space each pinned boundary opens for its label while dragging. The
* markers stay zero height at rest, so nothing is reserved until pickup. */
boundaryLabelHeight?: number;
@@ -140,11 +141,14 @@ export function createSidebarSortingStrategy(input: {
else slimHeight ??= rects[index]?.height;
if (item.key !== active.key) groups[item.section].push(item);
}
- // Cards are 4.875rem + 0.25rem padding; slim rows/placeholders are h-9.
- const scale =
- slimHeight !== undefined ? slimHeight / 36 : (headerScale ?? (cardHeight ?? 82) / 82);
- cardHeight ??= 82 * scale;
- slimHeight ??= 36 * scale;
+ // Compact icons use h-7; expanded cards include their vertical padding.
+ const scale = input.compact
+ ? (cardHeight ?? slimHeight ?? 28) / 28
+ : slimHeight !== undefined
+ ? slimHeight / 36
+ : (headerScale ?? (cardHeight ?? 82) / 82);
+ cardHeight ??= (input.compact ? 28 : 82) * scale;
+ slimHeight ??= (input.compact ? 28 : 36) * scale;
const labelHeight = (input.boundaryLabelHeight ?? 0) * scale;
const group = groups[target.section];
const order =
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index a5420ffb8a36..0cafc3f3585b 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -3604,6 +3604,7 @@ export default function Sidebar() {
() =>
createSidebarSortingStrategy({
items: sidebarListItems,
+ compact,
boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT,
settledOrder: draggedSettledOrder,
settledExpanded: settledShelfExpanded,
@@ -3612,6 +3613,7 @@ export default function Sidebar() {
snoozedThreadCount: snoozedThreads.length,
}),
[
+ compact,
draggedSettledOrder,
routeThreadKey,
settledShelfExpanded,
@@ -3628,7 +3630,6 @@ export default function Sidebar() {
const item = sidebarListItems[args.index];
// Footer rows stay anchored while the main list previews a reorder.
if (
- draggingCompactSnoozed ||
(item?.kind === "thread"
? item.section === "snoozed"
: item?.marker === "snoozed-header")
@@ -3637,7 +3638,7 @@ export default function Sidebar() {
}
return sidebarSortingStrategy(args);
},
- [draggingCompactSnoozed, sidebarListItems, sidebarSortingStrategy],
+ [sidebarListItems, sidebarSortingStrategy],
);
// Hidden and filtered threads keep their keys. Reserve those slots without
// including the rows in the visible drop order or writing to them.
@@ -4542,7 +4543,7 @@ export default function Sidebar() {
<>
0 ? (
,
);
@@ -4989,7 +4995,7 @@ export default function Sidebar() {
key="pinned-divider"
marker="pinned-divider"
label="Active"
- visible={from !== null}
+ visible={showDragLabels}
isDropTarget={dragTargetSection === "active"}
/>,
);
@@ -5075,24 +5081,18 @@ export default function Sidebar() {
compact && snoozedFooter
? createPortal(snoozedItems, snoozedFooter, "snoozed-footer")
: null,
- compact
+ compactSnoozedDragThread
? createPortal(
- {compactSnoozedDragThread ? (
-
- {renderThreadRowInner(
- compactSnoozedDragThread,
- "snoozed",
- {
- isDragging: true,
- listeners: undefined,
- setNodeRef: () => {},
- transform: null,
- transition: undefined,
- },
- )}
-
- ) : null}
+
+ {renderThreadRowInner(compactSnoozedDragThread, "snoozed", {
+ isDragging: true,
+ listeners: undefined,
+ setNodeRef: () => {},
+ transform: null,
+ transition: undefined,
+ })}
+
,
document.body,
"compact-snoozed-drag",
From 3daa20bf5825d09946b20ea385de45f156802a82 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Sun, 13 Sep 2026 07:55:36 +0000
Subject: [PATCH 10/11] style(web): format compact sidebar sorting
---
apps/web/src/components/Sidebar.tsx | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 0cafc3f3585b..1b850f1875ba 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -3630,9 +3630,7 @@ export default function Sidebar() {
const item = sidebarListItems[args.index];
// Footer rows stay anchored while the main list previews a reorder.
if (
- (item?.kind === "thread"
- ? item.section === "snoozed"
- : item?.marker === "snoozed-header")
+ item?.kind === "thread" ? item.section === "snoozed" : item?.marker === "snoozed-header"
) {
return null;
}
From 938e1d8dcfe84b16ff7d485f915722cafb12f166 Mon Sep 17 00:00:00 2001
From: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
Date: Sun, 13 Sep 2026 15:46:36 +0000
Subject: [PATCH 11/11] fix(web): keep compact sidebar toolbar icons visible
---
.../sidebar/SidebarThreadHeader.tsx | 99 +++++++------------
1 file changed, 38 insertions(+), 61 deletions(-)
diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx
index 51c7af57f3eb..d0718d9856f8 100644
--- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx
+++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx
@@ -10,21 +10,19 @@
* of the sidebar's scope logic. `searchFieldRef` lands on the search field so
* the picker's popup can anchor to that width rather than to its 28px trigger.
*/
-import { EllipsisIcon, FolderPlusIcon, SearchIcon, SquarePenIcon, XIcon } from "lucide-react";
+import { FolderPlusIcon, SearchIcon, SquarePenIcon, XIcon } from "lucide-react";
import {
type ComponentProps,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type ReactNode,
type RefObject,
- useState,
} from "react";
import { cn } from "~/lib/utils";
import { useCompactSidebarEnabled } from "../../hooks/useSettings";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
-import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
import { SidebarMenuButton, useSidebar } from "../ui/sidebar";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
@@ -75,7 +73,6 @@ export function SidebarThreadHeader({
const compactEnabled = useCompactSidebarEnabled();
const { state, isMobile, setOpen } = useSidebar();
const compact = compactEnabled && state === "collapsed" && !isMobile;
- const [actionsOpen, setActionsOpen] = useState(false);
const resultsVisible = isSearching && searchResultCount > 0;
// Results shrink as the query narrows, so the active index can outrun the
// list; pointing aria-activedescendant at a removed option strands the
@@ -85,13 +82,12 @@ export function SidebarThreadHeader({
? `New thread (${newThreadShortcutLabel})`
: "New thread";
- const actions = (
-
+ return (
+
{compact ? (
{
- setActionsOpen(false);
setOpen(true);
requestAnimationFrame(() => searchInputRef.current?.focus());
}}
@@ -99,48 +95,6 @@ export function SidebarThreadHeader({
) : null}
- {hasProjects ? (
- <>
- {projectScope}
- {
- setActionsOpen(false);
- onNewProject();
- }}
- >
-
-
- >
- ) : null}
-
- {newThreadLabel}
-
- New thread in current project: Shift+click
- {newThreadInProjectShortcutLabel ? ` (${newThreadInProjectShortcutLabel})` : ""}
-
-
- ) : (
- newThreadLabel
- )
- }
- disabled={newThreadDisabled}
- onClick={(event) => {
- setActionsOpen(false);
- onNewThread(event);
- }}
- >
-
-
-
- );
-
- return (
-
{/* Segmented well: the icons read as one control instead of three loose
buttons competing with the search field beside them. */}
- {compact ? (
-
- }>
-
-
-
- {actions}
-
-
- ) : (
- actions
- )}
+
+ {hasProjects ? (
+ <>
+ {projectScope}
+
+
+
+ >
+ ) : null}
+
+ {newThreadLabel}
+
+ New thread in current project: Shift+click
+ {newThreadInProjectShortcutLabel ? ` (${newThreadInProjectShortcutLabel})` : ""}
+
+
+ ) : (
+ newThreadLabel
+ )
+ }
+ disabled={newThreadDisabled}
+ onClick={onNewThread}
+ >
+
+
+
);
}