diff --git a/clients/macos/AGENTS.md b/clients/macos/AGENTS.md index 8cbb0a3ff3f..8e60e8f4dc6 100644 --- a/clients/macos/AGENTS.md +++ b/clients/macos/AGENTS.md @@ -284,6 +284,7 @@ All design system types use the `V` prefix (VButton, VColor, VFont, etc.). Alway Never trade `HStack+Spacer` for `.frame(alignment:)` in lazy containers — fewer layout nodes is not worth O(n) recursive alignment queries per node. - **No `.frame(maxHeight:)` on ScrollView inside LazyVStack cells**: `.frame(maxHeight:)` creates `_FlexFrameLayout` which measures the ScrollView's full content height before clamping — defeating lazy loading. Use the two-path pattern instead: long content gets `ScrollView { }.frame(height: fixedHeight)` (definite height, O(1)); short content renders directly with no ScrollView. See [`.frame(width:height:alignment:)`](https://developer.apple.com/documentation/swiftui/view/frame(width:height:alignment:)) vs [`.frame(minWidth:...maxHeight:...)`](https://developer.apple.com/documentation/swiftui/view/frame(minwidth:idealwidth:maxwidth:minheight:idealheight:maxheight:alignment:)). - **Use `.frame(width:)` (not `.frame(maxWidth:)`) on ScrollView containers with LazyVStack**: `.frame(width:)` creates [`_FrameLayout`](https://developer.apple.com/documentation/swiftui/view/frame(width:height:alignment:)) which returns `bounds.midX` for alignment without querying children — the alignment cascade stops here. `.frame(maxWidth:)` creates [`_FlexFrameLayout`](https://developer.apple.com/documentation/swiftui/view/frame(minwidth:idealwidth:maxwidth:minheight:idealheight:maxheight:alignment:)) which queries `explicitAlignment` on children, cascading O(n) through the entire `LazyVStack` subtree on every layout pass. Use a computed width (e.g. `min(containerWidth, maxWidth)`) to get responsive behavior without `_FlexFrameLayout`. Do NOT use a custom `Layout` container **between the outer modifier chain and the `ScrollView`** — custom `Layout` containers at that level disrupt SwiftUI's internal scroll infrastructure, causing intermittent cell materialization failures. (Note: `WidthCapLayout` / `.widthCap()` used *inside* cells is safe — it doesn't sit between the ScrollView and its parent.) +- **Chat-cell widths read from `@Environment(\.bubbleMaxWidth)`, not `VSpacing.chatBubbleMaxWidth` directly**: `MessageListContentView` sets the env to a container-aware value (`min(chatBubbleMaxWidth, chatColumnWidth - 2*xl)`); views inside the chat subtree (images, markdown content, inline previews, thinking blocks) must read it so they shrink with the window. The `VSpacing` token is only the static fallback for first-layout pass (env reports 0 until `GeometryReader` resolves) or non-chat contexts. `MarkdownSegmentView.maxContentWidth` in particular is applied as a definite `.frame(width:)` — callers passing a larger value than the actual container cause visible overflow. - **Gallery**: When adding or modifying a design system primitive/component, update the corresponding Gallery section file (`Gallery/Sections/`) so the visual catalog stays current. - **Accessibility**: See `clients/AGENTS.md` § [Accessibility](../AGENTS.md#accessibility) for the full checklist (labels, hidden elements, custom interactions, AppKit panels). All rules there apply to macOS components. diff --git a/clients/macos/vellum-assistant/Features/Chat/AnimatedImageView.swift b/clients/macos/vellum-assistant/Features/Chat/AnimatedImageView.swift index 617e9b07a7c..0b9461d2b86 100644 --- a/clients/macos/vellum-assistant/Features/Chat/AnimatedImageView.swift +++ b/clients/macos/vellum-assistant/Features/Chat/AnimatedImageView.swift @@ -18,6 +18,7 @@ struct AnimatedImageView: View { @State private var isLoading = true @State private var isGIF: Bool = false @Environment(\.displayScale) private var displayScale + @Environment(\.bubbleMaxWidth) private var bubbleMaxWidth // MARK: - In-memory cache @@ -45,16 +46,16 @@ struct AnimatedImageView: View { return cache }() - /// Maximum display dimension in points (matches text bubble maxWidth). - private let maxDimension: CGFloat = VSpacing.chatBubbleMaxWidth - var body: some View { + // `MessageListLayoutMetrics` reports 0 on the first layout pass before + // `GeometryReader` resolves; the token is the static fallback. + let maxDimension: CGFloat = bubbleMaxWidth > 0 ? bubbleMaxWidth : VSpacing.chatBubbleMaxWidth Group { if let data = imageData, isGIF { GIFView(data: data) .frame( - width: min(gifSize.width, maxDimension), - height: min(gifSize.height, maxDimension) + width: min(gifSize(maxDimension: maxDimension).width, maxDimension), + height: min(gifSize(maxDimension: maxDimension).height, maxDimension) ) } else if let image = loadedImage, let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) { @@ -96,7 +97,7 @@ struct AnimatedImageView: View { } } - private var gifSize: CGSize { + private func gifSize(maxDimension: CGFloat) -> CGSize { guard let image = loadedImage else { return CGSize(width: maxDimension, height: maxDimension) } let size = image.size guard size.width > 0, size.height > 0 else { return CGSize(width: maxDimension, height: maxDimension) } diff --git a/clients/macos/vellum-assistant/Features/Chat/ChatBubbleAttachmentContent.swift b/clients/macos/vellum-assistant/Features/Chat/ChatBubbleAttachmentContent.swift index b2f926c60fd..fb6d01f80e1 100644 --- a/clients/macos/vellum-assistant/Features/Chat/ChatBubbleAttachmentContent.swift +++ b/clients/macos/vellum-assistant/Features/Chat/ChatBubbleAttachmentContent.swift @@ -132,12 +132,14 @@ private struct AspectFitImageView: View { private struct InlineToolCallImageView: View { let image: NSImage @Environment(\.displayScale) private var displayScale + @Environment(\.bubbleMaxWidth) private var bubbleMaxWidth @State private var sharingServices: [NSSharingService] = [] @State private var displayImage: NSImage? @available(macOS, deprecated: 13.0) var body: some View { - AspectFitImageView(image: displayImage ?? image, maxDimension: VSpacing.chatBubbleMaxWidth) + let displayMax: CGFloat = bubbleMaxWidth > 0 ? bubbleMaxWidth : VSpacing.chatBubbleMaxWidth + AspectFitImageView(image: displayImage ?? image, maxDimension: displayMax) .onTapGesture { // Open lightbox with the original full-resolution image. AppDelegate.shared?.mainWindow?.windowState.showImageLightbox( @@ -204,6 +206,7 @@ private struct AttachmentImageGrid: View { /// gray-placeholder state. @State private var failedIds: Set = [] @Environment(\.displayScale) private var displayScale + @Environment(\.bubbleMaxWidth) private var bubbleMaxWidth /// Single images render at full width; multiple images use a compact grid. private var isSingleImage: Bool { imageAttachments.count == 1 } @@ -277,10 +280,13 @@ private struct AttachmentImageGrid: View { fallback(attachment) } else { // Placeholder shown while the image is being decoded off the main thread. + let placeholderSingleWidth: CGFloat = bubbleMaxWidth > 0 + ? bubbleMaxWidth + : VSpacing.chatBubbleMaxWidth Rectangle() .fill(VColor.surfaceActive) .frame( - width: isSingleImage ? VSpacing.chatBubbleMaxWidth : 160, + width: isSingleImage ? placeholderSingleWidth : 160, height: isSingleImage ? 200 : 120 ) .clipShape(RoundedRectangle(cornerRadius: VRadius.md)) @@ -293,6 +299,8 @@ private struct AttachmentImageGrid: View { .task(id: "\(attachment.id)-\(displayScale)") { let scale = displayScale if isSingleImage { + // Decode target is intentionally the static cap, not the env width: + // keying the task on a resize-varying value would re-decode every frame. let targetSize = CGSize(width: VSpacing.chatBubbleMaxWidth, height: VSpacing.chatBubbleMaxWidth) // Single images: prefer full-resolution data so the frame // sizing (which uses native pixel dimensions) is accurate. @@ -395,7 +403,8 @@ private struct AttachmentImageGrid: View { /// Full-width rendering for a single image attachment, matching tool-generated image sizing. private func singleImageContent(_ nsImage: NSImage) -> some View { - AspectFitImageView(image: nsImage, maxDimension: VSpacing.chatBubbleMaxWidth) + let displayMax: CGFloat = bubbleMaxWidth > 0 ? bubbleMaxWidth : VSpacing.chatBubbleMaxWidth + return AspectFitImageView(image: nsImage, maxDimension: displayMax) } /// Compact grid cell for multiple image attachments. diff --git a/clients/macos/vellum-assistant/Features/Chat/InlineFilePreviewView.swift b/clients/macos/vellum-assistant/Features/Chat/InlineFilePreviewView.swift index ec1f399d8c3..9f24cd08c3a 100644 --- a/clients/macos/vellum-assistant/Features/Chat/InlineFilePreviewView.swift +++ b/clients/macos/vellum-assistant/Features/Chat/InlineFilePreviewView.swift @@ -26,6 +26,7 @@ struct InlineFilePreviewView: View { private static let charThreshold = 50_000 @Environment(\.filePreviewExpansionStore) private var expansionStore + @Environment(\.bubbleMaxWidth) private var bubbleMaxWidth @State private var cachedContent: String? = nil @State private var isLoading: Bool = false @State private var loadError: Bool = false @@ -156,10 +157,13 @@ struct InlineFilePreviewView: View { } private var markdownContent: some View { + // `maxContentWidth` becomes a definite `.frame(width:)` inside + // `SelectableRunView`, so subtract the card's own `.padding(VSpacing.sm)` + // to keep the padded card at the chat-column width. MarkdownSegmentView( segments: cachedSegments, isStreaming: false, - maxContentWidth: nil, + maxContentWidth: max(bubbleMaxWidth - 2 * VSpacing.sm, 0), textColor: VColor.contentDefault, secondaryTextColor: VColor.contentSecondary, mutedTextColor: VColor.contentTertiary, diff --git a/clients/macos/vellum-assistant/Features/Chat/ThinkingBlockView.swift b/clients/macos/vellum-assistant/Features/Chat/ThinkingBlockView.swift index 501057925ff..fa6c169098f 100644 --- a/clients/macos/vellum-assistant/Features/Chat/ThinkingBlockView.swift +++ b/clients/macos/vellum-assistant/Features/Chat/ThinkingBlockView.swift @@ -16,6 +16,7 @@ struct ThinkingBlockView: View { var typographyGeneration: Int = 0 @Environment(\.thinkingBlockExpansionStore) private var expansionStore + @Environment(\.bubbleMaxWidth) private var bubbleMaxWidth /// Cached parsed markdown segments — parsed lazily only when the block is /// expanded, avoiding synchronous O(n) work while collapsed (the default). @@ -50,20 +51,15 @@ struct ThinkingBlockView: View { // ⚠️ No .frame(maxWidth:) in LazyVStack cells — see AGENTS.md. // - // `maxContentWidth` is the budget for the measured text run. - // `MarkdownSegmentView` falls back to `VSpacing.chatBubbleMaxWidth` - // when nil, and `SelectableRunView` applies that as a definite - // `.frame(width:)`. The `.padding(VSpacing.sm)` below then adds - // 8pt on each side, so passing `nil` makes the outer card 776pt - // wide — 16pt wider than the 760pt chat column, visibly mis- - // aligned with the adjacent progress card. Subtracting the - // padding from the budget keeps the padded card at exactly - // `chatBubbleMaxWidth`. + // `maxContentWidth` becomes a definite `.frame(width:)` inside + // `SelectableRunView`, so subtract the card's own + // `.padding(VSpacing.sm)` to keep the padded card at the chat + // column width. MarkdownSegmentView( segments: cachedSegments, isStreaming: isStreaming, typographyGeneration: typographyGeneration, - maxContentWidth: VSpacing.chatBubbleMaxWidth - 2 * VSpacing.sm, + maxContentWidth: max(bubbleMaxWidth - 2 * VSpacing.sm, 0), textColor: VColor.contentSecondary, secondaryTextColor: VColor.contentTertiary, mutedTextColor: VColor.contentTertiary,