Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion packages/HostUI/Sources/HostUI/HostUIControls.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,12 +165,32 @@ public struct HostUISidebar<Content: View>: 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<Content: View>: 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?
Expand Down
41 changes: 34 additions & 7 deletions packages/HostUI/Sources/HostUI/HostUIMessagingControls.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,17 @@ 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
self.onOpenThread = onOpenThread
}

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)
Expand All @@ -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)
}
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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)
Expand All @@ -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<String>,
Expand All @@ -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)

Expand All @@ -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)
}

Expand All @@ -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)
Expand Down
38 changes: 35 additions & 3 deletions packages/HostUI/Sources/HostUI/HostUIMessagingLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: " ")
Expand Down
65 changes: 52 additions & 13 deletions packages/HostUI/Sources/HostUI/HostUINodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -151,6 +158,7 @@ public struct HostUINodeView: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
}

@ViewBuilder
Expand Down Expand Up @@ -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)
Expand All @@ -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 = ""

Expand Down Expand Up @@ -359,7 +398,7 @@ private struct HostUIInboxPreviewHost: View {
],
channelDraft: $channelDraft,
threadDraft: $threadDraft,
isViewingReplyThread: true,
isViewingReplyThread: isViewingReplyThread,
replyThreadTitle: "$orchestrator weather in 07647"
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
Loading