From 93e96dde8ea1dc7b4c37f397c1760a2f575fda1f Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Wed, 30 Sep 2026 02:46:20 -0700 Subject: [PATCH] feat: one sidebar column, shown as a rail or as a panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail and the drawer held two copies of the same column — the same sections, in the same order, in two pieces of markup, so a change to one silently skipped the other. Both now render `sidebarColumn`, and the shells keep only what differs: the rail its width, resize handle and collapse button, the panel its outside-click catcher and its transform. A phone gets that panel rather than a modal. There was a width test deciding between two presentations; the card under the band's handle works at any width, so it goes. What is left of the width test is the choice between a rail and a panel, plus a `(hover: hover)` test — the old `innerWidth >= 768` was standing in for "has a pointer", which is what decides whether the panel closes when the pointer leaves it. With the card everywhere, the backdrop, the `aria-modal` (nothing dims and nothing traps focus) and the drawer's close cross all go: the band's handle opens the panel and a tap outside closes it. The burger row goes with them. Entering panel width now collapses the rail transiently, so coming back to a wide window does not land on a rail the page was resized away from. The band also stops easing its right edge. That transition belongs to the rail, which moves the band's left; its right follows a splitter the page owns, and easing it left the band lying over a session's side panel — a blank strip across that panel's own header for 200ms after every drag. Co-Authored-By: Claude Opus 5 (1M context) --- .../copilot/chat/AiChatLayout.svelte | 29 +- .../src/routes/(root)/(logged)/+layout.svelte | 498 +++++++----------- 2 files changed, 204 insertions(+), 323 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte index e2b8c9bba9..697a1a41d6 100644 --- a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte +++ b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte @@ -10,8 +10,6 @@ import { copilotInfo } from '$lib/aiStore' import { aiChatManager } from './AIChatManager.svelte' import { onDestroy } from 'svelte' - import Button from '$lib/components/common/button/Button.svelte' - import { Menu } from 'lucide-svelte' import CreatedResourceActionDrawers from './CreatedResourceActionDrawers.svelte' interface Props { @@ -21,7 +19,6 @@ transitionClass?: string isMobile?: boolean children: any - onMenuOpen?: () => void disableAi?: boolean // Whether this layout loads the workspace AI config. It gates far more than // the docked pane (code completion, metadata generation, the "open in AI @@ -41,7 +38,6 @@ transitionClass = 'transition-all ease-in-out duration-200', isMobile = false, children, - onMenuOpen, disableAi, loadAiConfig = true, showSessionsBetaBanner = false @@ -82,24 +78,8 @@ aiChatManager.cancel('aiChatLayout destroyed') historyManager.close() }) - -{#snippet burgerRow()} -
-
-{/snippet} + {#if !disableAi} @@ -112,7 +92,6 @@ >
- {@render burgerRow()}
{@render children?.()}
@@ -136,12 +115,14 @@ {/if} {:else} +
- {@render burgerRow()}
{@render children?.()}
diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index acc71834ad..8f15a82cd3 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -197,6 +197,15 @@ let sidebarTransitionClass = $derived( resizingSidebar && !sidebarSnapping ? '' : 'transition-all duration-200 ease-in-out' ) + // The band spans two edges that different things move. Its left follows the rail, so it + // eases with it; its right follows a splitter the page owns (a session's side panel), + // which tracks the pointer. Easing that one leaves the band lying over the panel it is + // meant to stop beside — an empty strip across that panel's own header until it catches up. + let bandTransitionClass = $derived( + resizingSidebar && !sidebarSnapping + ? '' + : 'transition-[left,padding-left] duration-200 ease-in-out' + ) // Set while a drag is live so it can be torn down if the layout unmounts // mid-drag (otherwise the window listeners would leak). let stopSidebarResize: (() => void) | null = null @@ -505,18 +514,20 @@ }) let innerWidth = $state(BROWSER ? window.innerWidth : 2000) - // The drawer holds the sidebar below 768px and, at any width, while it is detached. + // The sidebar leaves the layout and becomes a card under the band's handle when it is + // detached, and below 768px, where a 13rem rail would take a third of the screen. One + // presentation for both: a phone gets the card the handle already opens, not a modal. let useDrawer = $derived(innerWidth < 768 || navDetached.val) - // Below 768px the burger row opens the drawer; wider, a detached sidebar has a floating handle. - let detachedFloating = $derived(navDetached.val && innerWidth >= 768) + // Hover opened the card, so the pointer leaving closes it — where there is a pointer to + // leave. A tap opened it on a touch screen, and it stays until it is dismissed. + const hoverPointer = BROWSER && window.matchMedia('(hover: hover)').matches + const peeksOnHover = $derived(useDrawer && hoverPointer) // The peek card is a hover affordance, so it goes away when the pointer does — off the handle // and off the card both. The delay is what lets the pointer cross the gap between them: they // do not touch, and the leave of the one fires before the enter of the other. const PEEK_CLOSE_DELAY_MS = 200 const { debounced: schedulePeekClose, clearDebounce: cancelPeekClose } = debounce(() => { - // Only the floating card peeks; the mobile drawer is opened deliberately and stays until - // it is dismissed. - if (detachedFloating) menuOpen = false + if (peeksOnHover) menuOpen = false }, PEEK_CLOSE_DELAY_MS) // A deployed app owns the viewport: the band floats above it until the corner handle calls it @@ -742,8 +753,12 @@ } }) + // Narrow enough that the rail is in the way: collapse it, transiently (see `collapsedState`). + // The drawer widths below 768 are included — there the sidebar is a modal panel over the page, + // and a window that has been that narrow should come back to the icon rail rather than to a + // 13rem one it no longer has room for. function changeCollapsed() { - if (innerWidth < 1248 && innerWidth >= 768 && !collapsedState) { + if (innerWidth < 1248 && !collapsedState) { collapsedState = true } } @@ -1104,6 +1119,164 @@
{/snippet} + +{#snippet sidebarColumn(panel: boolean)} + {@const collapsed = panel ? false : isCollapsed} +
+ {#if !panel} + + + {/if} + + {#if !embedded && sessionsSwitchShown} +
+
+ (preserveMenuOnNextNav = true) : undefined} + /> +
+
+ {/if} + + {#if sessionMode} + +
+ +
+ {@render settingsMenu(collapsed)} + {:else} + + + +
+ {@render quickLinks(collapsed)} + + {#snippet children({ createMenu })} + + {/snippet} + + openSearchModal()} + isCollapsed={collapsed} + icon={Search} + label="Search" + class="!text-xs" + shortcut={`${getModifierKey()}k`} + /> + {#if askAiShown} + + aiChatManager.toggleOpen()} + isCollapsed={collapsed} + icon={WandSparkles} + iconProps={{ forceDarkMode: true }} + label="Ask AI" + class="!text-xs" + iconClasses="!text-ai" + shortcut={`${getModifierKey()}L`} + /> + {/if} +
+ + + + + +
+ {@render settingsMenu(collapsed)} +
+
+ {/if} + +
+ {@render accountSetupBanner(collapsed)} + +
+ +
+ {@render brandMark(collapsed)} +
+ {#if !panel || navDetached.val} + {@render sidebarToggle()} + {/if} + + {#if !panel && !$userStore?.operator} + + {/if} +
+
+
+{/snippet} + {#if accountSetup.pending}
{/if} {#if !menuHidden} - {#if detachedFloating && !menuOpen && !devOnly} + {#if navDetached.val && hoverPointer && !menuOpen && !devOnly}
- - {#if !detachedFloating} -
- {/if} - - - + +
{ if (e.target === e.currentTarget) menuOpen = false }} @@ -1199,143 +1360,16 @@ if (bandPeek) scheduleBandHide() }} class={classNames( - 'relative flex-1 flex flex-col max-w-min w-full bg-surface transition ease-in-out duration-300 transform', - detachedFloating - ? fullBleed - ? 'border-r shadow-lg overflow-hidden' - : 'rounded-lg border shadow-lg overflow-hidden' - : '', + 'relative flex-1 flex flex-col max-w-min w-full bg-surface transition ease-in-out duration-300 transform shadow-lg overflow-hidden', + fullBleed ? 'border-r' : 'rounded-lg border', menuOpen ? 'translate-x-0' - : detachedFloating && !fullBleed - ? '-translate-x-[calc(100%+0.5rem)]' - : '-translate-x-full' + : fullBleed + ? '-translate-x-full' + : '-translate-x-[calc(100%+0.5rem)]' )} > -
- -
-
- - {#if !embedded && sessionsSwitchShown} -
- (preserveMenuOnNextNav = true)} - /> -
- {/if} - - {#if sessionMode} - -
- -
- {@render settingsMenu(false)} - {:else} - - - -
- {@render quickLinks(false)} - - {#snippet children({ createMenu })} - - {/snippet} - - openSearchModal()} - isCollapsed={false} - icon={Search} - label="Search" - class="!text-xs" - shortcut={`${getModifierKey()}k`} - /> - {#if askAiShown} - - aiChatManager.toggleOpen()} - isCollapsed={false} - icon={WandSparkles} - iconProps={{ forceDarkMode: true }} - label="Ask AI" - class="!text-xs" - iconClasses="!text-ai" - shortcut={`${getModifierKey()}L`} - /> - {/if} -
- - - - - -
- {@render settingsMenu(false)} -
-
- {/if} - -
- {@render accountSetupBanner(false)} - -
- - -
- {@render brandMark(false)} - {#if detachedFloating} -
- {@render sidebarToggle()} -
- {/if} -
-
+ {@render sidebarColumn(true)}
@@ -1353,141 +1387,7 @@ in:railMorph={{ offscreen: false }} out:railMorph={{ offscreen: true }} > -
- - - - {#if !embedded && sessionsSwitchShown} -
-
- -
-
- {/if} - - {#if sessionMode} - -
- -
- {@render settingsMenu(isCollapsed)} - {:else} - - - -
- {@render quickLinks(isCollapsed)} - - {#snippet children({ createMenu })} - - {/snippet} - - openSearchModal()} - {isCollapsed} - icon={Search} - label="Search" - class="!text-xs" - shortcut={`${getModifierKey()}k`} - /> - {#if askAiShown} - - aiChatManager.toggleOpen()} - {isCollapsed} - icon={WandSparkles} - iconProps={{ forceDarkMode: true }} - label="Ask AI" - class="!text-xs" - iconClasses="!text-ai" - shortcut={`${getModifierKey()}L`} - /> - {/if} -
- - - - - -
- {@render settingsMenu(isCollapsed)} -
-
- {/if} - -
- {@render accountSetupBanner(isCollapsed)} - -
- -
- {@render brandMark(isCollapsed)} -
- {@render sidebarToggle()} - {#if !$userStore?.operator} - - {/if} -
-
-
+ {@render sidebarColumn(false)} {/if} @@ -1608,7 +1508,7 @@ bandPeek ? (bandPeekSettled ? 'transition-transform duration-150 ease-out ' : '') + (bandShown ? 'translate-y-0' : '-translate-y-full pointer-events-none') - : sidebarTransitionClass + : bandTransitionClass )} style:left={floatBand ? `${leftInset}rem` : undefined} style:right={floatBand ? `${rightInset ?? 0}px` : undefined} @@ -1633,6 +1533,7 @@ (appHeaderPinned.val = false) : undefined} @@ -1733,7 +1634,6 @@ sidebarWidth={railWidth} transitionClass={sidebarTransitionClass} isMobile={useDrawer} - onMenuOpen={() => navHandleSlot.open()} />