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
1 change: 1 addition & 0 deletions clients/macos/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -204,6 +206,7 @@ private struct AttachmentImageGrid<Fallback: View>: View {
/// gray-placeholder state.
@State private var failedIds: Set<String> = []
@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 }
Expand Down Expand Up @@ -277,10 +280,13 @@ private struct AttachmentImageGrid<Fallback: View>: 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))
Expand All @@ -293,6 +299,8 @@ private struct AttachmentImageGrid<Fallback: View>: 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.
Expand Down Expand Up @@ -395,7 +403,8 @@ private struct AttachmentImageGrid<Fallback: View>: 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand Down