From 5a7d7d446e33a5db480a3ce99f5d4dfefd8c79f0 Mon Sep 17 00:00:00 2001 From: David Choi Date: Sun, 20 Sep 2026 22:34:18 -0400 Subject: [PATCH] Size HostUI panes from the window instead of 300/360/440 point locks. Keep bubble width as a fraction of the pane so channel, thread, and composer track narrow and wide layouts. Co-authored-by: Cursor --- .../Sources/HostUI/HostUIControls.swift | 22 ++++++- .../HostUI/HostUIMessagingControls.swift | 41 ++++++++++-- .../HostUI/HostUIMessagingLayout.swift | 38 ++++++++++- .../Sources/HostUI/HostUINodeView.swift | 65 +++++++++++++++---- .../HostUIMessagingLayoutTests.swift | 16 +++++ 5 files changed, 158 insertions(+), 24 deletions(-) diff --git a/packages/HostUI/Sources/HostUI/HostUIControls.swift b/packages/HostUI/Sources/HostUI/HostUIControls.swift index 1b040a7..bc5686e 100644 --- a/packages/HostUI/Sources/HostUI/HostUIControls.swift +++ b/packages/HostUI/Sources/HostUI/HostUIControls.swift @@ -165,12 +165,32 @@ public struct HostUISidebar: View { public var body: some View { content - .frame(minWidth: 300, idealWidth: 360, maxWidth: 440) .frame(maxHeight: .infinity, alignment: .top) + .containerRelativeFrame(.horizontal) { width, _ in + HostUIMessagingLayout.sidebarWidth(forContainer: width) + } .clipped() } } +/// Publishes the pane width so gutters and padding track the channel or thread, not a point lock. +struct HostUIPaneWidthReader: View { + @ViewBuilder var content: () -> Content + + var body: some View { + GeometryReader { geo in + content() + .environment(\.hostUIPaneWidth, geo.size.width) + .frame(width: geo.size.width, height: geo.size.height, alignment: .topLeading) + } + } +} + +extension EnvironmentValues { + /// Width of the current HostUI channel or thread column. + @Entry var hostUIPaneWidth: CGFloat = 720 +} + public struct HostUISelect: View { private let options: [HostUITabItem] private let selectedID: String? diff --git a/packages/HostUI/Sources/HostUI/HostUIMessagingControls.swift b/packages/HostUI/Sources/HostUI/HostUIMessagingControls.swift index 60e9aa9..689620d 100644 --- a/packages/HostUI/Sources/HostUI/HostUIMessagingControls.swift +++ b/packages/HostUI/Sources/HostUI/HostUIMessagingControls.swift @@ -120,6 +120,7 @@ public struct HostUIMessageRow: Identifiable, Hashable, Sendable { public struct HostUIMessage: View { private let row: HostUIMessageRow private let onOpenThread: (() -> Void)? + @Environment(\.hostUIPaneWidth) private var paneWidth public init(row: HostUIMessageRow, onOpenThread: (() -> Void)? = nil) { self.row = row @@ -127,8 +128,9 @@ public struct HostUIMessage: View { } public var body: some View { + let gutter = HostUIMessagingLayout.oppositeGutter(forPane: paneWidth) HStack(alignment: .top, spacing: 0) { - if row.outbound { Spacer(minLength: HostUIMessagingLayout.oppositeGutter) } + if row.outbound { Spacer(minLength: gutter) } VStack(alignment: row.outbound ? .trailing : .leading, spacing: 4) { if !row.sender.isEmpty { Text(row.sender) @@ -144,7 +146,7 @@ public struct HostUIMessage: View { } } .frame(maxWidth: .infinity, alignment: row.outbound ? .trailing : .leading) - if !row.outbound { Spacer(minLength: HostUIMessagingLayout.oppositeGutter) } + if !row.outbound { Spacer(minLength: gutter) } } .fixedSize(horizontal: false, vertical: true) } @@ -194,6 +196,7 @@ public struct HostUIMessageList: View { private let onNearBottomChange: ((Bool) -> Void)? private let onLoadOlder: (() -> Void)? private let scrollToBottomToken: Int + @Environment(\.hostUIPaneWidth) private var paneWidth public init( rows: [HostUIMessageRow], @@ -230,7 +233,7 @@ public struct HostUIMessageList: View { .onAppear { onNearBottomChange?(true) } .onDisappear { onNearBottomChange?(false) } } - .padding(.horizontal, HostUIMessagingLayout.listPaddingX) + .padding(.horizontal, HostUIMessagingLayout.listPaddingX(forPane: paneWidth)) .padding(.vertical, 12) } .frame(minHeight: 0) @@ -257,6 +260,7 @@ public struct HostUIComposer: View { private let isSending: Bool private let canSend: Bool private let onSubmit: () -> Void + @Environment(\.hostUIPaneWidth) private var paneWidth public init( text: Binding, @@ -275,11 +279,13 @@ public struct HostUIComposer: View { } public var body: some View { + let chrome = HostUIMessagingLayout.composerPaddingX(forPane: paneWidth) + let inset = HostUIMessagingLayout.listPaddingX(forPane: paneWidth) VStack(alignment: .leading, spacing: 0) { HostUITextField(placeholder, text: $text, axis: .vertical, chrome: .plain) .onSubmit(submitIfAllowed) - .padding(.horizontal, 18) - .padding(.top, 18) + .padding(.horizontal, chrome) + .padding(.top, chrome) .padding(.bottom, 12) .frame(minHeight: 76, alignment: .topLeading) @@ -296,14 +302,14 @@ public struct HostUIComposer: View { action: submitIfAllowed ) } - .padding(.horizontal, 18) + .padding(.horizontal, chrome) .padding(.vertical, 12) .background(Color(red: 248.0 / 255.0, green: 248.0 / 255.0, blue: 246.0 / 255.0)) } .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .background(.white, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) .shadow(color: .black.opacity(0.06), radius: 8, x: 0, y: 2) - .padding(.horizontal, 12) + .padding(.horizontal, inset) .padding(.vertical, 8) } @@ -319,6 +325,27 @@ public struct HostUIComposer: View { } } +#Preview("Composer narrow") { + HostUIComposerPreviewHost(initial: "Message") + .frame(width: 520) + .padding() + .background(Color(red: 245 / 255, green: 244 / 255, blue: 240 / 255)) +} + +#Preview("Composer default") { + HostUIComposerPreviewHost(initial: "") + .frame(width: 720) + .padding() + .background(Color(red: 245 / 255, green: 244 / 255, blue: 240 / 255)) +} + +#Preview("Composer wide") { + HostUIComposerPreviewHost(initial: "hello from channel") + .frame(width: 1100) + .padding() + .background(Color(red: 245 / 255, green: 244 / 255, blue: 240 / 255)) +} + #Preview("Messaging composer") { HostUIComposerPreviewHost(initial: "") .frame(width: 720) diff --git a/packages/HostUI/Sources/HostUI/HostUIMessagingLayout.swift b/packages/HostUI/Sources/HostUI/HostUIMessagingLayout.swift index c4faa09..0b6f9c3 100644 --- a/packages/HostUI/Sources/HostUI/HostUIMessagingLayout.swift +++ b/packages/HostUI/Sources/HostUI/HostUIMessagingLayout.swift @@ -14,11 +14,43 @@ public enum HostUIMessagingLayout { public static let maxBubbleFraction: CGFloat = 0.72 /// Ceiling so a 5K channel does not become one full-width paragraph. public static let maxBubbleWidth: CGFloat = 520 - /// Gutter opposite the bubble. Keep small so a reply pane can still wrap. - public static let oppositeGutter: CGFloat = 24 - public static let listPaddingX: CGFloat = 12 + /// Reply pane share of the HostUI screen. Not a 300/360/440 point lock. + public static let sidebarFraction: CGFloat = 0.34 + public static let sidebarMinFraction: CGFloat = 0.26 + public static let sidebarMaxFraction: CGFloat = 0.45 public static let replyPreviewCharacterCap = 96 + public static func sidebarWidth(forContainer width: CGFloat) -> CGFloat { + guard width.isFinite, width > 1 else { return 0 } + let proposed = width * sidebarFraction + let lower = width * sidebarMinFraction + let upper = width * sidebarMaxFraction + return min(max(proposed, lower), upper) + } + + public static func oppositeGutter(forPane width: CGFloat) -> CGFloat { + scaled(width: width, fraction: 0.04, floor: 12, cap: 48, fallback: 24) + } + + public static func listPaddingX(forPane width: CGFloat) -> CGFloat { + scaled(width: width, fraction: 0.018, floor: 8, cap: 20, fallback: 12) + } + + public static func composerPaddingX(forPane width: CGFloat) -> CGFloat { + scaled(width: width, fraction: 0.025, floor: 12, cap: 22, fallback: 18) + } + + private static func scaled( + width: CGFloat, + fraction: CGFloat, + floor: CGFloat, + cap: CGFloat, + fallback: CGFloat + ) -> CGFloat { + guard width.isFinite, width > 1 else { return fallback } + return min(max(width * fraction, floor), cap) + } + public static func collapsedReplyPreview(_ raw: String) -> String { let oneLine = raw .replacingOccurrences(of: "\n", with: " ") diff --git a/packages/HostUI/Sources/HostUI/HostUINodeView.swift b/packages/HostUI/Sources/HostUI/HostUINodeView.swift index ac2a97f..a57e8f8 100644 --- a/packages/HostUI/Sources/HostUI/HostUINodeView.swift +++ b/packages/HostUI/Sources/HostUI/HostUINodeView.swift @@ -60,18 +60,21 @@ public struct HostUINodeView: View { @ViewBuilder private func genericStack(_ root: HostUINode) -> some View { - VStack(spacing: 0) { - ForEach(Array(HostUIScreenLayout.visibleChildren(of: root).enumerated()), id: \.offset) { _, child in - if child.element == HostUIElementID.sidebar.rawValue { - if shouldShowSidebar([child]) { - HostUISidebar { - sidebarColumn(child) + HostUIPaneWidthReader { + VStack(spacing: 0) { + ForEach(Array(HostUIScreenLayout.visibleChildren(of: root).enumerated()), id: \.offset) { _, child in + if child.element == HostUIElementID.sidebar.rawValue { + if shouldShowSidebar([child]) { + HostUISidebar { + sidebarColumn(child) + } } + } else { + nodeContent(child, isThread: false) } - } else { - nodeContent(child, isThread: false) } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } @@ -102,17 +105,21 @@ public struct HostUINodeView: View { @ViewBuilder private func mainColumn(_ nodes: [HostUINode]) -> some View { - VStack(spacing: 0) { - ForEach(Array(nodes.enumerated()), id: \.offset) { _, child in - nodeContent(child, isThread: false) + HostUIPaneWidthReader { + VStack(spacing: 0) { + ForEach(Array(nodes.enumerated()), id: \.offset) { _, child in + nodeContent(child, isThread: false) + } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } @ViewBuilder private func sidebarColumn(_ sidebar: HostUINode) -> some View { - VStack(spacing: 0) { + HostUIPaneWidthReader { + VStack(spacing: 0) { HStack { Text(bindings.replyThreadTitle) .font(.headline) @@ -151,6 +158,7 @@ public struct HostUINodeView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } } @ViewBuilder @@ -280,6 +288,36 @@ public struct HostUINodeView: View { } } +#Preview("Channel narrow") { + HostUIInboxPreviewHost(emptyGuestSidebar: true, isViewingReplyThread: false) + .frame(width: 520, height: 640) +} + +#Preview("Channel default") { + HostUIInboxPreviewHost(emptyGuestSidebar: true, isViewingReplyThread: false) + .frame(width: 880, height: 720) +} + +#Preview("Channel wide") { + HostUIInboxPreviewHost(emptyGuestSidebar: true, isViewingReplyThread: false) + .frame(width: 1440, height: 800) +} + +#Preview("Thread narrow") { + HostUIInboxPreviewHost(emptyGuestSidebar: false) + .frame(width: 520, height: 640) +} + +#Preview("Thread default") { + HostUIInboxPreviewHost(emptyGuestSidebar: false) + .frame(width: 880, height: 720) +} + +#Preview("Thread wide") { + HostUIInboxPreviewHost(emptyGuestSidebar: false) + .frame(width: 1440, height: 800) +} + #Preview("Reply pane filled") { HostUIInboxPreviewHost(emptyGuestSidebar: false) .frame(width: 1100, height: 720) @@ -292,6 +330,7 @@ public struct HostUINodeView: View { private struct HostUIInboxPreviewHost: View { var emptyGuestSidebar: Bool + var isViewingReplyThread: Bool = true @State private var channelDraft = "" @State private var threadDraft = "" @@ -359,7 +398,7 @@ private struct HostUIInboxPreviewHost: View { ], channelDraft: $channelDraft, threadDraft: $threadDraft, - isViewingReplyThread: true, + isViewingReplyThread: isViewingReplyThread, replyThreadTitle: "$orchestrator weather in 07647" ) } diff --git a/packages/HostUI/Tests/HostUITests/HostUIMessagingLayoutTests.swift b/packages/HostUI/Tests/HostUITests/HostUIMessagingLayoutTests.swift index 8fb59a5..470da6a 100644 --- a/packages/HostUI/Tests/HostUITests/HostUIMessagingLayoutTests.swift +++ b/packages/HostUI/Tests/HostUITests/HostUIMessagingLayoutTests.swift @@ -21,6 +21,22 @@ import Testing #expect(HostUIMessagingLayout.huggingWidth(for: long) == HostUIMessagingLayout.maxBubbleWidth) } + @Test func sidebarAndChromeScaleWithTheContainerNotPointLocks() { + let narrow = HostUIMessagingLayout.sidebarWidth(forContainer: 520) + let wide = HostUIMessagingLayout.sidebarWidth(forContainer: 1440) + #expect(narrow < wide) + #expect(abs(narrow - 520 * HostUIMessagingLayout.sidebarFraction) < 0.51) + #expect(abs(wide - 1440 * HostUIMessagingLayout.sidebarFraction) < 0.51) + #expect(narrow != 300) + #expect(wide != 440) + + let narrowGutter = HostUIMessagingLayout.oppositeGutter(forPane: 280) + let wideGutter = HostUIMessagingLayout.oppositeGutter(forPane: 900) + #expect(narrowGutter < wideGutter) + #expect(HostUIMessagingLayout.listPaddingX(forPane: 280) <= HostUIMessagingLayout.listPaddingX(forPane: 900)) + #expect(HostUIMessagingLayout.composerPaddingX(forPane: 520) <= HostUIMessagingLayout.composerPaddingX(forPane: 1100)) + } + @Test func replyPreviewIsOneLineAndCapped() { let preview = HostUIMessagingLayout.collapsedReplyPreview( "First paragraph.\n\nSecond paragraph that must not become a channel message."