@@ -43,6 +43,8 @@ import { stripSkillPrefix } from './lib/slashCommands';
4343import Button from ' ./components/ui/Button.vue' ;
4444import IconButton from ' ./components/ui/IconButton.vue' ;
4545import Icon from ' ./components/ui/Icon.vue' ;
46+ import InternalBuildBanner from ' ./components/InternalBuildBanner.vue' ;
47+ import { isMacosDesktop } from ' ./lib/desktopFlag' ;
4648
4749// Hydrate the server-transport credential (fragment token or sessionStorage)
4850// BEFORE the client connects, so the first REST/WS calls already carry it.
@@ -213,6 +215,7 @@ const {
213215 sidebarMax,
214216 sessionColWidth,
215217 sidebarCollapsed,
218+ sidebarDragging,
216219 sideWidth,
217220 loadSidebarCollapsed,
218221 toggleSidebarCollapse,
@@ -645,13 +648,18 @@ function openPr(url: string): void {
645648 <div
646649 v-else
647650 class =" app"
648- :class =" { mobile: isMobile, 'sidebar-collapsed': sidebarCollapsed && !isMobile }"
649- :style =" { '--side-w': sideWidth + 'px', '--preview-w': previewPanelWidth + 'px' }"
651+ :class =" {
652+ mobile: isMobile,
653+ 'sidebar-collapsed': sidebarCollapsed && !isMobile,
654+ 'macos-desktop': isMacosDesktop,
655+ }"
656+ :style =" { '--preview-w': previewPanelWidth + 'px' }"
650657 >
651658 <!-- Desktop navigation: workspace rail + resizable session column. -->
652659 <template v-if =" ! isMobile " >
653660 <Sidebar
654- v-show =" ! sidebarCollapsed "
661+ :collapsed =" sidebarCollapsed "
662+ :dragging =" sidebarDragging "
655663 :col-width =" sideWidth "
656664 :active-workspace =" client .visibleWorkspace .value "
657665 :active-workspace-id =" client .activeWorkspaceId .value "
@@ -681,21 +689,14 @@ function openPr(url: string): void {
681689 />
682690 <ResizeHandle
683691 v-show =" ! sidebarCollapsed "
692+ class="side-handle"
684693 :storage-key =" SIDEBAR_WIDTH_KEY "
685694 :default-width =" SIDEBAR_DEFAULT "
686695 :min =" SIDEBAR_MIN "
687696 :max =" sidebarMax "
688697 @update :width =" sessionColWidth = $event "
698+ @update :dragging =" sidebarDragging = $event "
689699 />
690- <div v-if =" sidebarCollapsed" class =" sidebar-rail" >
691- <IconButton
692- size="sm"
693- :label =" t (' sidebar.expandSidebar' )"
694- @click =" toggleSidebarCollapse "
695- >
696- <Icon name="panel-expand" size="sm" />
697- </IconButton >
698- </div >
699700 </template >
700701
701702 <!-- Mobile navigation: slim top bar (switcher + settings sheets). -->
@@ -792,8 +793,29 @@ function openPr(url: string): void {
792793 @edit-message =" handleEditMessage "
793794 />
794795
796+ <!-- Sidebar toggle — floating only when the in-header control can't serve:
797+ on macOS desktop it's RESIDENT (always rendered beside the traffic
798+ lights, the sidebar slides underneath and only the glyph swaps, so it
799+ never moves or flashes); on Windows/web the collapse button lives
800+ inside the sidebar header, so this floating button only appears while
801+ COLLAPSED (to re-expand the sidebar). It must come AFTER
802+ ConversationPane in the DOM: Electron computes the window-drag region
803+ in tree order (drag rects union, no-drag rects subtract), so a no-drag
804+ element placed before the ChatHeader drag region would have its hole
805+ painted back over — making the button an inert drag area. -->
806+ <IconButton
807+ v-if =" ! isMobile && (isMacosDesktop || sidebarCollapsed )"
808+ class="sidebar-toggle-btn"
809+ size="sm"
810+ :label =" sidebarCollapsed ? t (' sidebar.expandSidebar' ) : t (' sidebar.collapseSidebar' )"
811+ @click =" toggleSidebarCollapse "
812+ >
813+ <Icon :name =" sidebarCollapsed ? ' panel-expand' : ' panel-collapse' " />
814+ </IconButton >
815+
795816 <ResizeHandle
796817 v-if =" sidePanelVisible && ! isMobile "
818+ class="preview-handle"
797819 :storage-key =" PREVIEW_WIDTH_KEY "
798820 :default-width =" previewDefaultWidth "
799821 :min =" PREVIEW_MIN "
@@ -875,6 +897,11 @@ function openPr(url: string): void {
875897 />
876898 </aside >
877899
900+ <!-- Internal-build tag — pinned to the app's bottom-right corner, above
901+ whatever pane happens to be there. Purely informational: pointer
902+ events pass through so it never blocks clicks. -->
903+ <InternalBuildBanner class="internal-build-fab" />
904+
878905 <!-- Model Picker overlay -->
879906 <ModelPicker
880907 v-if =" showModelPicker "
@@ -1101,18 +1128,20 @@ function openPr(url: string): void {
11011128 color : var (--dim );
11021129}
11031130.app {
1104- --side-w : 248px ;
11051131 --preview-w : 460px ;
11061132 flex : 1 ;
11071133 min-height : 0 ;
1134+ position : relative ;
11081135 display : grid ;
1109- /* sidebar (rail + resizable session column) | 0-width handle | conversation.
1110- The 4px ResizeHandle overflows its zero-width track via negative margins so
1111- the whole strip is grabbable without consuming layout space. */
1112- /* The right-panel track is PERMANENT (auto = follows the aside's width, 0
1113- when closed) — opening animates the aside's width, so the conversation
1114- column is squeezed over smoothly instead of snapping to a new template. */
1115- grid-template-columns : var (--side-w ) 0 minmax (0 , 1fr ) 0 auto ;
1136+ /* sidebar | 0-width handle | conversation | 0-width handle | right panel.
1137+ The 4px ResizeHandles overflow their zero-width tracks via negative margins
1138+ so the whole strip is grabbable without consuming layout space. */
1139+ /* Both side tracks are PERMANENT (auto = follows the aside's width, 0 when
1140+ closed/collapsed) — opening or collapsing animates the aside's width, so
1141+ the conversation column is squeezed over smoothly instead of snapping to a
1142+ new template. Every column is pinned explicitly (grid-column 1–5) so a
1143+ display:none handle can't shift auto-placement. */
1144+ grid-template-columns : auto 0 minmax (0 , 1fr ) 0 auto ;
11161145 background : var (--bg );
11171146 color : var (--color-text );
11181147 overflow : hidden ;
@@ -1126,20 +1155,50 @@ function openPr(url: string): void {
11261155 min-width : 0 ;
11271156}
11281157
1129- /* Collapsed sidebar rail: keeps a slim, dedicated grid track so the expand
1130- button never overlaps the conversation header or squeezes the main pane. */
1131- .sidebar-rail {
1132- grid-column : 1 ;
1133- display : flex ;
1134- justify-content : center ;
1135- padding-top : 8px ;
1136- background : var (--panel );
1137- border-right : 1px solid var (--line );
1158+ /* Pin every desktop grid child to its track so auto-placement can never
1159+ reshuffle columns when a handle is display:none (v-show/v-if). */
1160+ .app > .side { grid-column : 1 ; }
1161+ .side-handle { grid-column : 2 ; }
1162+ .app :not (.mobile ) > .con { grid-column : 3 ; }
1163+ .preview-handle { grid-column : 4 ; }
1164+
1165+ /* Sidebar toggle — floating button pinned to the top-left corner. On macOS
1166+ desktop it is resident (rendered in both states beside the traffic lights);
1167+ on Windows/web it only appears while the sidebar is collapsed (the collapse
1168+ button lives inside the sidebar header). While collapsed the conversation
1169+ header pads left so its content clears the button (global block below). */
1170+ .sidebar-toggle-btn {
1171+ position : absolute ;
1172+ /* Vertically centered in the 48px conversation header. */
1173+ top : 11px ;
1174+ left : 16px ;
1175+ z-index : var (--z-sticky );
1176+ /* Fade in on appearance (Windows/web: only rendered while collapsed, so
1177+ this plays as the sidebar finishes sliding away). macOS disables it. */
1178+ animation : sidebar-toggle-btn-in 0.18s var (--ease-out ) 0.12s backwards ;
1179+ /* Floats over the macOS-desktop window-drag header; keep it clickable. */
1180+ -webkit-app-region : no-drag;
11381181}
1139- /* The collapsed rail occupies track 1; keep the main pane pinned to the
1140- conversation track even though the sidebar/handle are display:none. */
1141- .app.sidebar-collapsed > .con {
1142- grid-column : 3 ;
1182+ /* macOS desktop (hidden title bar): resident beside the floating traffic
1183+ lights (green light's right edge ≈ 68px; 72 keeps a gap that matches the
1184+ lights' own 8px rhythm); no entrance animation since it never appears. */
1185+ .app.macos-desktop .sidebar-toggle-btn {
1186+ left : 72px ;
1187+ animation : none ;
1188+ }
1189+ @keyframes sidebar-toggle-btn-in {
1190+ from { opacity : 0 ; }
1191+ }
1192+
1193+ /* Internal-build tag pinned to the app's bottom-right corner (desktop app
1194+ only — the component renders nothing elsewhere). Informational: never
1195+ intercepts pointer input. */
1196+ .internal-build-fab {
1197+ position : absolute ;
1198+ right : var (--space-3 );
1199+ bottom : var (--space-3 );
1200+ z-index : var (--z-sticky );
1201+ pointer-events : none ;
11431202}
11441203
11451204/* Mobile single-column shell: slim top bar (auto) over the full-width
@@ -1208,4 +1267,19 @@ function openPr(url: string): void {
12081267 one continuous line across the layout. */
12091268 --panel-head-h : 48px ;
12101269}
1270+
1271+ /* Sidebar collapsed (desktop): the conversation header pads left so its
1272+ content clears the floating sidebar toggle (.sidebar-toggle-btn) — and the
1273+ macOS traffic lights on desktop builds. Animated in step with the sidebar
1274+ width transition. Cross-component rule (ChatHeader renders the header), so
1275+ it lives in this global block. */
1276+ .app :not (.mobile ) .chat-header {
1277+ transition : padding-left 0.28s cubic-bezier (0.4 , 0 , 0.2 , 1 );
1278+ }
1279+ .app.sidebar-collapsed .chat-header {
1280+ padding-left : 52px ;
1281+ }
1282+ .app.sidebar-collapsed.macos-desktop .chat-header {
1283+ padding-left : 108px ;
1284+ }
12111285 </style >
0 commit comments