diff --git a/clients/shared/DesignSystem/Modifiers/WidthCapLayout.swift b/clients/shared/DesignSystem/Modifiers/WidthCapLayout.swift new file mode 100644 index 00000000000..1327fcef40d --- /dev/null +++ b/clients/shared/DesignSystem/Modifiers/WidthCapLayout.swift @@ -0,0 +1,41 @@ +import SwiftUI + +/// Caps proposed width at a maximum value using the Layout protocol (O(1)). +/// Drop-in replacement for `.frame(maxWidth: N)` that avoids +/// `_FlexFrameLayout` and its O(n × depth) `explicitAlignment` cascade +/// inside LazyVStack cells. +/// +/// Reference: [Layout.explicitAlignment](https://developer.apple.com/documentation/swiftui/layout/explicitalignment(of:in:proposal:subviews:cache:)-8ofeu) +struct WidthCapLayout: Layout { + let cap: CGFloat + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let available = proposal.replacingUnspecifiedDimensions().width + let width = min(cap, available) + guard let child = subviews.first else { return CGSize(width: width, height: 0) } + let childSize = child.sizeThatFits(ProposedViewSize(width: width, height: proposal.height)) + return CGSize(width: width, height: childSize.height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + guard let child = subviews.first else { return } + child.place( + at: bounds.origin, + anchor: .topLeading, + proposal: ProposedViewSize(width: bounds.width, height: bounds.height) + ) + } +} + +extension View { + /// Caps width at `cap` without creating `_FlexFrameLayout`. + /// When `cap` is nil, no constraint is applied. + @ViewBuilder + func widthCap(_ cap: CGFloat?) -> some View { + if let cap { + WidthCapLayout(cap: cap) { self } + } else { + self + } + } +} diff --git a/clients/shared/Features/Chat/CommandListBubble.swift b/clients/shared/Features/Chat/CommandListBubble.swift index 2294e6630b3..1a863344833 100644 --- a/clients/shared/Features/Chat/CommandListBubble.swift +++ b/clients/shared/Features/Chat/CommandListBubble.swift @@ -75,7 +75,7 @@ public struct CommandListBubble: View { RoundedRectangle(cornerRadius: VRadius.lg) .stroke(VColor.borderBase, lineWidth: 1) ) - .frame(maxWidth: 400) + .widthCap(400) } private static func parseEntry(from rawLine: Substring) -> CommandEntry? { diff --git a/clients/shared/Features/Chat/InlineWidgets/InlineAppCreatedCard.swift b/clients/shared/Features/Chat/InlineWidgets/InlineAppCreatedCard.swift index 373e1e574d5..e63b22f3374 100644 --- a/clients/shared/Features/Chat/InlineWidgets/InlineAppCreatedCard.swift +++ b/clients/shared/Features/Chat/InlineWidgets/InlineAppCreatedCard.swift @@ -25,7 +25,6 @@ struct InlineAppCreatedCard: View { Image(nsImage: nsImage) .resizable() .aspectRatio(contentMode: .fill) - .frame(maxWidth: .infinity) .frame(height: 140) .clipShape(RoundedRectangle(cornerRadius: VRadius.md)) } diff --git a/clients/shared/Features/Chat/InlineWidgets/InlineDynamicPagePreview.swift b/clients/shared/Features/Chat/InlineWidgets/InlineDynamicPagePreview.swift index 067444de765..d7705a16597 100644 --- a/clients/shared/Features/Chat/InlineWidgets/InlineDynamicPagePreview.swift +++ b/clients/shared/Features/Chat/InlineWidgets/InlineDynamicPagePreview.swift @@ -15,64 +15,66 @@ public struct InlineDynamicPagePreview: View { Button { onViewOutput() } label: { - VStack(alignment: .leading, spacing: VSpacing.xl) { - // Icon + title row - HStack(spacing: VSpacing.sm) { - if let icon = preview.icon { - if let url = URL(string: icon), url.scheme == "https" || url.scheme == "http" { - AsyncImage(url: url) { phase in - switch phase { - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fit) - case .failure: - RoundedRectangle(cornerRadius: VRadius.sm) - .fill(VColor.surfaceOverlay) - default: - RoundedRectangle(cornerRadius: VRadius.sm) - .fill(VColor.surfaceOverlay) + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: VSpacing.xl) { + // Icon + title row + HStack(spacing: VSpacing.sm) { + if let icon = preview.icon { + if let url = URL(string: icon), url.scheme == "https" || url.scheme == "http" { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fit) + case .failure: + RoundedRectangle(cornerRadius: VRadius.sm) + .fill(VColor.surfaceOverlay) + default: + RoundedRectangle(cornerRadius: VRadius.sm) + .fill(VColor.surfaceOverlay) + } } + .frame(width: 32, height: 32) + .clipShape(RoundedRectangle(cornerRadius: VRadius.sm)) + } else { + Text(icon) + .font(.system(size: 28)) } - .frame(width: 32, height: 32) - .clipShape(RoundedRectangle(cornerRadius: VRadius.sm)) - } else { - Text(icon) - .font(.system(size: 28)) } - } - VStack(alignment: .leading, spacing: VSpacing.xxs) { - Text(preview.title) - .font(VFont.bodyMediumEmphasised) - .foregroundStyle(VColor.contentDefault) - .lineLimit(2) + VStack(alignment: .leading, spacing: VSpacing.xxs) { + Text(preview.title) + .font(VFont.bodyMediumEmphasised) + .foregroundStyle(VColor.contentDefault) + .lineLimit(2) - if let subtitle = preview.subtitle { - Text(subtitle) - .font(VFont.labelDefault) - .foregroundStyle(VColor.contentTertiary) - .lineLimit(1) + if let subtitle = preview.subtitle { + Text(subtitle) + .font(VFont.labelDefault) + .foregroundStyle(VColor.contentTertiary) + .lineLimit(1) + } } } - } - if let description = preview.description, !description.isEmpty { - Text(description) - .font(VFont.labelDefault) - .foregroundStyle(VColor.contentSecondary) - .lineLimit(3) - } + if let description = preview.description, !description.isEmpty { + Text(description) + .font(VFont.labelDefault) + .foregroundStyle(VColor.contentSecondary) + .lineLimit(3) + } - if let metrics = preview.metrics, !metrics.isEmpty { - HStack(spacing: VSpacing.sm) { - ForEach(Array(metrics.prefix(3).enumerated()), id: \.offset) { _, metric in - metricPill(label: metric.label, value: metric.value) + if let metrics = preview.metrics, !metrics.isEmpty { + HStack(spacing: VSpacing.sm) { + ForEach(Array(metrics.prefix(3).enumerated()), id: \.offset) { _, metric in + metricPill(label: metric.label, value: metric.value) + } } } } + Spacer(minLength: 0) } - .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) diff --git a/clients/shared/Features/Chat/InlineWidgets/InlineSurfaceRouter.swift b/clients/shared/Features/Chat/InlineWidgets/InlineSurfaceRouter.swift index 80fce7d6224..b8c2f978dcb 100644 --- a/clients/shared/Features/Chat/InlineWidgets/InlineSurfaceRouter.swift +++ b/clients/shared/Features/Chat/InlineWidgets/InlineSurfaceRouter.swift @@ -6,43 +6,6 @@ private let log = Logger( category: "InlineSurfaceRouter" ) -/// Caps proposed width at a maximum value using the Layout protocol (O(1)). -/// Drop-in replacement for `.frame(maxWidth:, alignment: .leading)` that -/// avoids `_FlexFrameLayout` and its O(n × depth) alignment cascade. -private struct WidthCapLayout: Layout { - let cap: CGFloat - - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { - let available = proposal.replacingUnspecifiedDimensions().width - let width = min(cap, available) - guard let child = subviews.first else { return CGSize(width: width, height: 0) } - let childSize = child.sizeThatFits(ProposedViewSize(width: width, height: proposal.height)) - return CGSize(width: width, height: childSize.height) - } - - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - guard let child = subviews.first else { return } - child.place( - at: bounds.origin, - anchor: .topLeading, - proposal: ProposedViewSize(width: bounds.width, height: bounds.height) - ) - } -} - -extension View { - /// Caps width at `cap` without creating `_FlexFrameLayout`. - /// When `cap` is nil, no constraint is applied. - @ViewBuilder - fileprivate func widthCap(_ cap: CGFloat?) -> some View { - if let cap { - WidthCapLayout(cap: cap) { self } - } else { - self - } - } -} - /// Routes an `InlineSurfaceData` to the correct inline widget view. public struct InlineSurfaceRouter: View { public let surface: InlineSurfaceData diff --git a/clients/shared/Features/Chat/InlineWidgets/InlineTableWidget.swift b/clients/shared/Features/Chat/InlineWidgets/InlineTableWidget.swift index ca7fcb6aa27..f2d32768420 100644 --- a/clients/shared/Features/Chat/InlineWidgets/InlineTableWidget.swift +++ b/clients/shared/Features/Chat/InlineWidgets/InlineTableWidget.swift @@ -389,7 +389,7 @@ public struct InlineTableWidget: View { endResize(for: columnIndex) } ) - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(width: resizeHandleWidth) } .contentShape(Rectangle()) #else diff --git a/clients/shared/Features/Chat/ModelListBubble.swift b/clients/shared/Features/Chat/ModelListBubble.swift index 84c79715823..a4550b22552 100644 --- a/clients/shared/Features/Chat/ModelListBubble.swift +++ b/clients/shared/Features/Chat/ModelListBubble.swift @@ -103,6 +103,6 @@ public struct ModelListBubble: View { RoundedRectangle(cornerRadius: VRadius.lg) .stroke(VColor.borderBase, lineWidth: 1) ) - .frame(maxWidth: 480) + .widthCap(480) } } diff --git a/clients/shared/Features/Chat/ToolCallChip.swift b/clients/shared/Features/Chat/ToolCallChip.swift index c2f8d8dfa4b..246f0277145 100644 --- a/clients/shared/Features/Chat/ToolCallChip.swift +++ b/clients/shared/Features/Chat/ToolCallChip.swift @@ -155,31 +155,37 @@ public struct ToolCallChip: View { let canOpenImage = !toolCall.inputRawValue.isEmpty && FileManager.default.fileExists(atPath: toolCall.inputRawValue) if canOpenImage { - Image(nsImage: cachedImage) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: .infinity) - .clipShape(RoundedRectangle(cornerRadius: VRadius.sm)) - .padding(.horizontal, VSpacing.sm) - .onTapGesture(count: 2) { - NSWorkspace.shared.open(URL(fileURLWithPath: toolCall.inputRawValue)) - } - .pointerCursor() + HStack(spacing: 0) { + Image(nsImage: cachedImage) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: VRadius.sm)) + .onTapGesture(count: 2) { + NSWorkspace.shared.open(URL(fileURLWithPath: toolCall.inputRawValue)) + } + .pointerCursor() + Spacer(minLength: 0) + } + .padding(.horizontal, VSpacing.sm) } else { - Image(nsImage: cachedImage) + HStack(spacing: 0) { + Image(nsImage: cachedImage) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: VRadius.sm)) + Spacer(minLength: 0) + } + .padding(.horizontal, VSpacing.sm) + } + #elseif os(iOS) + HStack(spacing: 0) { + Image(uiImage: cachedImage) .resizable() .aspectRatio(contentMode: .fit) - .frame(maxWidth: .infinity) .clipShape(RoundedRectangle(cornerRadius: VRadius.sm)) - .padding(.horizontal, VSpacing.sm) + Spacer(minLength: 0) } - #elseif os(iOS) - Image(uiImage: cachedImage) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: .infinity) - .clipShape(RoundedRectangle(cornerRadius: VRadius.sm)) - .padding(.horizontal, VSpacing.sm) + .padding(.horizontal, VSpacing.sm) #endif } @@ -231,11 +237,13 @@ public struct ToolCallChip: View { VDiffView(result, maxHeight: lineCount > 500 ? 400 : nil) } else { ScrollView { - Text(result) - .font(VFont.bodySmallDefault) - .foregroundStyle(VColor.contentSecondary) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) + HStack(spacing: 0) { + Text(result) + .font(VFont.bodySmallDefault) + .foregroundStyle(VColor.contentSecondary) + .textSelection(.enabled) + Spacer(minLength: 0) + } } .adaptiveScrollFrame(for: result, maxHeight: 400, lineCount: lineCount) } diff --git a/clients/shared/Features/Chat/ToolCallProgressBar.swift b/clients/shared/Features/Chat/ToolCallProgressBar.swift index 232c5d88789..788de8c86e2 100644 --- a/clients/shared/Features/Chat/ToolCallProgressBar.swift +++ b/clients/shared/Features/Chat/ToolCallProgressBar.swift @@ -41,7 +41,6 @@ public struct ToolCallProgressBar: View { } } } - .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, VSpacing.md) // Expanded details (shown when a step is clicked) @@ -192,17 +191,21 @@ public struct ToolCallProgressBar: View { .foregroundStyle(VColor.contentTertiary) #if os(macOS) - Image(nsImage: cachedImage) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: .infinity) - .clipShape(RoundedRectangle(cornerRadius: VRadius.sm)) + HStack(spacing: 0) { + Image(nsImage: cachedImage) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: VRadius.sm)) + Spacer(minLength: 0) + } #elseif os(iOS) - Image(uiImage: cachedImage) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: .infinity) - .clipShape(RoundedRectangle(cornerRadius: VRadius.sm)) + HStack(spacing: 0) { + Image(uiImage: cachedImage) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: VRadius.sm)) + Spacer(minLength: 0) + } #endif } } @@ -248,11 +251,13 @@ public struct ToolCallProgressBar: View { } } else { ScrollView { - Text(result) - .font(VFont.bodySmallDefault) - .foregroundStyle(VColor.contentSecondary) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) + HStack(spacing: 0) { + Text(result) + .font(VFont.bodySmallDefault) + .foregroundStyle(VColor.contentSecondary) + .textSelection(.enabled) + Spacer(minLength: 0) + } } .adaptiveScrollFrame(for: result, maxHeight: 200, lineThreshold: 12, lineCount: cachedResultLineCount) }