From bfab01a30e797ad6af99212f16209501f61d8f1b Mon Sep 17 00:00:00 2001 From: Carl Seelye Date: Fri, 10 Jul 2026 12:25:56 -0400 Subject: [PATCH 1/3] docs: document Activity drawer sizing Record the resize affordance, shared bounds, and per-build persistence. Co-authored-by: Codex --- memory-bank/uxArchitecture.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/memory-bank/uxArchitecture.md b/memory-bank/uxArchitecture.md index c8d3dfe..1df3b94 100644 --- a/memory-bank/uxArchitecture.md +++ b/memory-bank/uxArchitecture.md @@ -233,8 +233,7 @@ Not a modal. Not an inline expanding pane. A **persistent bottom drawer** that o โ”‚ ๐Ÿบ /opt/homebrew/Cellar/wget/1.21.4: 89 files, 4.5MB โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ ``` - -- **Default height:** 280px (โ‰ˆ 30% of 720 default window). User-draggable handle. +- **Sizing:** default height is 280 px (โ‰ˆ 30% of the 720-px default window). User-draggable handle to resize. The expanded drawer has a 252-px minimum (200-pt console plus drawer chrome) and a maximum of 60% of the current window height. The current height persists across sessions using localStorage on Tauri builds or UserDefaults on SwiftUI builds. - **Header strip:** current op name, elapsed time, collapse `โŒƒ` and dismiss `โœ•`. When collapsed, only the strip remains (~32px tall). - **Console body:** monospace, system mono (SF Mono), 12px. Each event line includes microsecond-relative timestamp on hover. Auto-scroll with smart pause when user scrolls up. - **History tabs:** when more than one op has run this session, drawer header shows tabs: `wget` (active) ยท `git (succeeded)` ยท `node (failed)`. Closing a completed-op tab removes it. The full history is also browsable in the **Activity** sidebar section. From d3fe63bc5ddc281bb9d17164057a47400e06c96f Mon Sep 17 00:00:00 2001 From: Carl Seelye Date: Fri, 10 Jul 2026 11:47:09 -0400 Subject: [PATCH 2/3] feat: make Activity console resizable in the native SwiftUI Use the native vertical split view for smooth Activity drawer resizing. Persist the drawer height, retain the existing minimum console size, and limit the expanded drawer to 60% of the window content height. Add focused sizing-boundary coverage. Co-authored-by: Codex --- .../Sources/BrewBrowserKit/ActivityView.swift | 26 +++- .../Sources/BrewBrowserKit/ContentView.swift | 114 +++++++++++++----- .../ActivityDrawerTests.swift | 21 ++++ 3 files changed, 126 insertions(+), 35 deletions(-) create mode 100644 native/Tests/BrewBrowserKitTests/ActivityDrawerTests.swift diff --git a/native/Sources/BrewBrowserKit/ActivityView.swift b/native/Sources/BrewBrowserKit/ActivityView.swift index bda194e..4783779 100644 --- a/native/Sources/BrewBrowserKit/ActivityView.swift +++ b/native/Sources/BrewBrowserKit/ActivityView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppKit /// Bottom Activity drawer โ€” live console for the active streaming job (install / /// upgrade / uninstall / update). Mirrors the Tauri `ActivityDrawer`: collapsed @@ -7,6 +8,23 @@ import SwiftUI /// switcher. Mounted as a bottom bar below the split view. struct ActivityDrawer: View { @Bindable var model: AppModel + /// `VSplitView` owns the live divider when expanded. The collapsed drawer + /// keeps its existing compact bar layout. + let fillsAvailableHeight: Bool + + init(model: AppModel, fillsAvailableHeight: Bool = false) { + self.model = model + self.fillsAvailableHeight = fillsAvailableHeight + } + + /// The existing 200 px console plus the header and native split divider. + /// This keeps an expanded drawer from becoming smaller than before. + static let minimumConsoleHeight: CGFloat = 200 + static let minimumDrawerHeight: CGFloat = 252 + + static func clampedDrawerHeight(_ height: CGFloat, maximum: CGFloat) -> CGFloat { + min(max(height, minimumDrawerHeight), max(minimumDrawerHeight, maximum)) + } /// The drawer shows the explicitly-active job only. nil `activeJobId` /// (after Close) hides the drawer entirely; selecting a job in the Activity @@ -47,7 +65,7 @@ struct ActivityDrawer: View { // Full bleed edge-to-edge so the bar covers the window's rounded // bottom corners (no dark notch where the split-view corner shows // through). .bar material fills the whole width. - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: fillsAvailableHeight && model.drawerOpen ? .infinity : nil) .background(.bar) // Auto-collapse the console when a job finishes successfully โ€” keeps // the one-line status visible (+ in Activity history) without the @@ -142,12 +160,13 @@ struct ActivityDrawer: View { private func expandedContent(_ job: ActivityJob) -> some View { HStack(alignment: .top, spacing: 0) { console(job) - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: fillsAvailableHeight ? .infinity : nil) if job.status == .failed { failureNotice(job) .frame(width: 360) } } + .frame(maxHeight: fillsAvailableHeight ? .infinity : nil) } @ViewBuilder @@ -244,7 +263,8 @@ struct ActivityDrawer: View { .padding(.horizontal, 12) .padding(.vertical, 6) } - .frame(height: 200) + .frame(minHeight: Self.minimumConsoleHeight, + maxHeight: fillsAvailableHeight ? .infinity : Self.minimumConsoleHeight) .onChange(of: job.lines.count) { _, count in if count > 0 { proxy.scrollTo(count - 1, anchor: .bottom) } } diff --git a/native/Sources/BrewBrowserKit/ContentView.swift b/native/Sources/BrewBrowserKit/ContentView.swift index 6e9a177..73d1c21 100644 --- a/native/Sources/BrewBrowserKit/ContentView.swift +++ b/native/Sources/BrewBrowserKit/ContentView.swift @@ -26,10 +26,15 @@ public struct ContentView: View { var updater: UpdaterController @Environment(\.openSettings) private var openSettings @Environment(\.scenePhase) private var scenePhase + private static let activityDrawerHeightKey = "activity.drawerHeight.v2" + @State private var initialActivityDrawerHeight: CGFloat public init(model: AppModel, updater: UpdaterController) { self.model = model self.updater = updater + let savedHeight = UserDefaults.standard.object(forKey: Self.activityDrawerHeightKey) as? Double + ?? Double(ActivityDrawer.minimumDrawerHeight) + _initialActivityDrawerHeight = State(initialValue: CGFloat(savedHeight)) } public var body: some View { @@ -47,7 +52,83 @@ public struct ContentView: View { } private var mainContent: some View { - VStack(spacing: 0) { + activityLayout + // In-window toasts (Bundle F) โ€” layered top-trailing over everything so + // they're clear of the bottom Activity drawer. See Toast.swift. + .overlay { ToastOverlay(model: model) } + .task { + model.loadJobs() + model.loadVulns() + if model.installed.isEmpty { await model.loadLibrary() } + } + // โŒ˜K command palette โ€” stock `.sheet` overlay (BrewBrowserApp's .commands + // flips `paletteOpen`). The catalog backs the palette's index search, so + // make sure it's loaded the first time the palette opens. + .sheet(isPresented: $model.paletteOpen) { + CommandPaletteView(model: model) + } + // Custom About box (replaces the bare standard panel) โ€” opened from the + // app menu's "About brew-browser". Mirrors the Tauri AboutModal. + .sheet(isPresented: $model.aboutOpen) { + AboutView(model: model) + } + // Esc closes the open detail inspector. The palette is a `.sheet`, which + // handles its own Esc, so by the time Esc reaches here the palette is + // already closed and only the inspector remains. Returns `.ignored` when + // there's nothing to close so Esc keeps its default behavior elsewhere. + .onKeyPress(.escape) { + model.closeTopmostOverlay() ? .handled : .ignored + } + } + + @ViewBuilder + private var activityLayout: some View { + if model.drawerOpen, model.activeJobId != nil { + // Match the sidebar's smooth native divider: VSplitView is AppKit- + // backed, so live resizing does not recreate the console hierarchy. + VSplitView { + navigationContent + resizableActivityDrawer + } + } else { + VStack(spacing: 0) { + navigationContent + ActivityDrawer(model: model) + } + } + } + + private var maximumActivityDrawerHeight: CGFloat { + let window = NSApp.keyWindow ?? NSApp.mainWindow + return max(ActivityDrawer.minimumDrawerHeight, (window?.contentLayoutRect.height ?? 720) * 0.6) + } + + private var resizableActivityDrawer: some View { + let maximum = maximumActivityDrawerHeight + let ideal = ActivityDrawer.clampedDrawerHeight(initialActivityDrawerHeight, maximum: maximum) + return ActivityDrawer(model: model, fillsAvailableHeight: true) + .frame(minHeight: ActivityDrawer.minimumDrawerHeight, + idealHeight: ideal, + maxHeight: maximum) + // The split view owns the current size. Persisting the observed size + // does not feed it back into this layout, avoiding resize feedback. + .background { + GeometryReader { proxy in + Color.clear + .onAppear { persistActivityDrawerHeight(proxy.size.height) } + .onChange(of: proxy.size.height) { _, height in + persistActivityDrawerHeight(height) + } + } + } + } + + private func persistActivityDrawerHeight(_ height: CGFloat) { + guard height >= ActivityDrawer.minimumDrawerHeight else { return } + UserDefaults.standard.set(Double(height), forKey: Self.activityDrawerHeightKey) + } + + private var navigationContent: some View { NavigationSplitView { List(Section.allCases, selection: $model.selection) { section in Label(section.rawValue, systemImage: section.symbol) @@ -215,37 +296,6 @@ public struct ContentView: View { .inspectorColumnWidth(min: 360, ideal: 400, max: 560) } } - // Activity drawer as a true full-width bottom bar BELOW the whole split - // view (sibling, not an inset/overlay) โ€” so the split view + the - // inspector's own footer live entirely above it and nothing is covered. - ActivityDrawer(model: model) - } - // In-window toasts (Bundle F) โ€” layered top-trailing over everything so - // they're clear of the bottom Activity drawer. See Toast.swift. - .overlay { ToastOverlay(model: model) } - .task { - model.loadJobs() - model.loadVulns() - if model.installed.isEmpty { await model.loadLibrary() } - } - // โŒ˜K command palette โ€” stock `.sheet` overlay (BrewBrowserApp's .commands - // flips `paletteOpen`). The catalog backs the palette's index search, so - // make sure it's loaded the first time the palette opens. - .sheet(isPresented: $model.paletteOpen) { - CommandPaletteView(model: model) - } - // Custom About box (replaces the bare standard panel) โ€” opened from the - // app menu's "About brew-browser". Mirrors the Tauri AboutModal. - .sheet(isPresented: $model.aboutOpen) { - AboutView(model: model) - } - // Esc closes the open detail inspector. The palette is a `.sheet`, which - // handles its own Esc, so by the time Esc reaches here the palette is - // already closed and only the inspector remains. Returns `.ignored` when - // there's nothing to close so Esc keeps its default behavior elsewhere. - .onKeyPress(.escape) { - model.closeTopmostOverlay() ? .handled : .ignored - } } @ViewBuilder diff --git a/native/Tests/BrewBrowserKitTests/ActivityDrawerTests.swift b/native/Tests/BrewBrowserKitTests/ActivityDrawerTests.swift new file mode 100644 index 0000000..027895b --- /dev/null +++ b/native/Tests/BrewBrowserKitTests/ActivityDrawerTests.swift @@ -0,0 +1,21 @@ +import Testing +@testable import BrewBrowserKit + +@Suite("ActivityDrawer sizing") +struct ActivityDrawerSizingTests { + @Test func preservesExistingDrawerHeightAsMinimum() { + #expect(ActivityDrawer.clampedDrawerHeight(120, maximum: 500) == 252) + } + + @Test func capsDrawerAtTheWindowBound() { + #expect(ActivityDrawer.clampedDrawerHeight(500, maximum: 360) == 360) + } + + @Test func retainsAValidPersistedHeight() { + #expect(ActivityDrawer.clampedDrawerHeight(280, maximum: 360) == 280) + } + + @Test func favorsTheMinimumWhenBoundsWouldOtherwiseConflict() { + #expect(ActivityDrawer.clampedDrawerHeight(100, maximum: 150) == 252) + } +} From 4096af67c2461c3a80e5b7c60de52f07e03fe827 Mon Sep 17 00:00:00 2001 From: Carl Seelye Date: Fri, 10 Jul 2026 12:40:53 -0400 Subject: [PATCH 3/3] feat: make Activity drawer resizable in Tauri Add a horizontal splitter with keyboard controls, persist the expanded height in localStorage, and enforce a 252 px minimum and 60% window cap. Co-authored-by: Codex --- src/lib/components/ActivityDrawer.svelte | 36 ++++++++- src/lib/components/ResizeHandle.svelte | 99 +++++++++++++++--------- src/lib/stores/ui.svelte.ts | 39 ++++++++++ src/routes/+page.svelte | 22 +++++- 4 files changed, 152 insertions(+), 44 deletions(-) diff --git a/src/lib/components/ActivityDrawer.svelte b/src/lib/components/ActivityDrawer.svelte index d5ef460..9bcaa45 100644 --- a/src/lib/components/ActivityDrawer.svelte +++ b/src/lib/components/ActivityDrawer.svelte @@ -10,7 +10,12 @@ import AlertTriangle from "@lucide/svelte/icons/alert-triangle"; import { activity } from "$lib/stores/activity.svelte"; - import { ui } from "$lib/stores/ui.svelte"; + import ResizeHandle from "$lib/components/ResizeHandle.svelte"; + import { + ACTIVITY_DRAWER_DEFAULT_HEIGHT, + ACTIVITY_DRAWER_MIN_HEIGHT, + ui, + } from "$lib/stores/ui.svelte"; import { toast } from "$lib/stores/toast.svelte"; import type { ActivityLine } from "$lib/types"; import { openReportIssueFromJob } from "$lib/util/reportIssue"; @@ -22,6 +27,12 @@ type RecoveryKind, } from "$lib/util/recovery"; + type Props = { + maxHeight: number; + }; + + let { maxHeight }: Props = $props(); + let consoleEl: HTMLDivElement | undefined = $state(); let autoScroll = $state(true); @@ -230,7 +241,24 @@ {#if ui.drawerOpen} -
+
+ {#if !ui.drawerMinimized} + (ui.drawerHeight = height)} + onCommit={(height) => ui.setActivityDrawerHeight(height)} + /> + {/if}