From 17925cd4b9a1c6ad54c450c59b615c226b14b720 Mon Sep 17 00:00:00 2001 From: byteoxo Date: Sat, 25 Jul 2026 19:36:45 +0800 Subject: [PATCH 1/2] fix(ai-chat): keep AI chat inside and centered in the right panel when resized When the right panel shrank, AI chat kept a wider ideal layout (model picker .fixedSize(), unbound messages/tables, composer text container not tracking width), centered in the host, and overflowed into the editor. Clamp the panel to the host, let the model picker truncate, reflow messages and code, scroll wide tables inside the bubble, and sync the composer text container to the panel width on layout. --- .../Views/AIChat/AIChatCodeBlockView.swift | 14 ++++++- TablePro/Views/AIChat/AIChatMessageView.swift | 7 +++- TablePro/Views/AIChat/AIChatPanelView.swift | 15 ++++--- .../Views/AIChat/AIChatToolUseBlockView.swift | 4 ++ .../Views/AIChat/ChatComposerTextView.swift | 36 +++++++++++++++++ TablePro/Views/AIChat/MarkdownView.swift | 39 +++++++++++-------- .../RightSidebar/UnifiedRightPanelView.swift | 3 ++ 7 files changed, 92 insertions(+), 26 deletions(-) diff --git a/TablePro/Views/AIChat/AIChatCodeBlockView.swift b/TablePro/Views/AIChat/AIChatCodeBlockView.swift index 8ddb2a3d4..d6b4d11c0 100644 --- a/TablePro/Views/AIChat/AIChatCodeBlockView.swift +++ b/TablePro/Views/AIChat/AIChatCodeBlockView.swift @@ -53,19 +53,22 @@ struct AIChatCodeBlockView: View, Equatable { } private var codeBlockHeader: some View { - HStack { + HStack(spacing: 8) { if let resolved = resolvedLanguage { Text(resolved.uppercased()) .font(.caption2) .fontWeight(.medium) .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color(nsColor: .separatorColor)) .clipShape(RoundedRectangle(cornerRadius: 4)) + .layoutPriority(-1) } - Spacer() + Spacer(minLength: 0) Button { ClipboardService.shared.writeText(code) @@ -80,9 +83,11 @@ struct AIChatCodeBlockView: View, Equatable { systemImage: isCopied ? "checkmark" : "doc.on.doc" ) .font(.caption2) + .lineLimit(1) } .buttonStyle(.plain) .foregroundStyle(.secondary) + .fixedSize() if isInsertable { Button { @@ -90,9 +95,11 @@ struct AIChatCodeBlockView: View, Equatable { } label: { Label(String(localized: "Insert"), systemImage: "square.and.pencil") .font(.caption2) + .lineLimit(1) } .buttonStyle(.plain) .foregroundStyle(.secondary) + .fixedSize() .disabled(actions == nil) .help(actions == nil ? String(localized: "Open a connection to insert") @@ -119,7 +126,9 @@ struct AIChatCodeBlockView: View, Equatable { configuration: Self.makeConfiguration(), state: $editorState ) + .frame(maxWidth: .infinity) .frame(height: editorHeight) + .clipped() } } @@ -214,6 +223,7 @@ private struct CodeBlockGroupBoxStyle: GroupBoxStyle { configuration.content } + .frame(maxWidth: .infinity, alignment: .leading) .background(Color(nsColor: .controlBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay( diff --git a/TablePro/Views/AIChat/AIChatMessageView.swift b/TablePro/Views/AIChat/AIChatMessageView.swift index bd7a792c3..6f04a99f0 100644 --- a/TablePro/Views/AIChat/AIChatMessageView.swift +++ b/TablePro/Views/AIChat/AIChatMessageView.swift @@ -28,7 +28,7 @@ struct AIChatMessageView: View { if message.role == .user { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 4) { - Spacer() + Spacer(minLength: 0) Text("You") .fontWeight(.medium) Text("ยท") @@ -47,7 +47,7 @@ struct AIChatMessageView: View { if let onEdit { HStack { - Spacer() + Spacer(minLength: 0) Button { onEdit() } label: { Image(systemName: "pencil") .font(.caption2) @@ -60,6 +60,7 @@ struct AIChatMessageView: View { } } .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) .background(Color.accentColor.opacity(Self.userBubbleTintOpacity)) .clipShape(RoundedRectangle(cornerRadius: 8)) } else { @@ -152,8 +153,10 @@ struct AIChatMessageView: View { VStack(alignment: .leading, spacing: 6) { ForEach(visibleBlocks) { block in AIChatBlockView(block: block) + .frame(maxWidth: .infinity, alignment: .leading) } } + .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 6) } } diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index ed8f8f40a..123fc04b8 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -43,6 +43,8 @@ struct AIChatPanelView: View { inputArea } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .clipped() .onAppear { viewModel.connection = connection } @@ -127,9 +129,11 @@ struct AIChatPanelView: View { ? { viewModel.editMessage(message) } : nil ) .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) .id(message.id) } } + .frame(maxWidth: .infinity) .padding(.horizontal, 8) .padding(.vertical, 8) .scrollTargetLayout() @@ -174,6 +178,7 @@ struct AIChatPanelView: View { .accessibilityLabel(String(localized: "Scroll to latest message")) } } + .frame(maxWidth: .infinity, maxHeight: .infinity) } // MARK: - Error Banner @@ -249,11 +254,12 @@ struct AIChatPanelView: View { slashCommandMenu modeMenu modelPicker - Spacer() + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) sendOrStopButton } } .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) } } @@ -293,6 +299,7 @@ struct AIChatPanelView: View { Image(systemName: settingsManager.ai.chatMode.symbolName) Text(settingsManager.ai.chatMode.displayName) .lineLimit(1) + .truncationMode(.tail) Image(systemName: "chevron.up.chevron.down") .font(.caption2) } @@ -300,7 +307,6 @@ struct AIChatPanelView: View { .foregroundStyle(.secondary) } .menuStyle(.borderlessButton) - .fixedSize() .help(settingsManager.ai.chatMode.helpText) } @@ -360,14 +366,15 @@ struct AIChatPanelView: View { Text(label) .lineLimit(1) .truncationMode(.middle) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) Image(systemName: "chevron.up.chevron.down") .font(.caption2) } .font(.caption) .foregroundStyle(.secondary) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) } .menuStyle(.borderlessButton) - .fixedSize() .help(String(localized: "Choose AI provider and model")) } } @@ -420,7 +427,6 @@ struct AIChatPanelView: View { .foregroundStyle(.secondary) } .menuStyle(.borderlessButton) - .fixedSize() .help(String(localized: "Attach context")) .accessibilityLabel(String(localized: "Attach context")) } @@ -460,7 +466,6 @@ struct AIChatPanelView: View { .foregroundStyle(.secondary) } .menuStyle(.borderlessButton) - .fixedSize() .help(String(localized: "Slash commands")) .accessibilityLabel(String(localized: "Slash commands")) } diff --git a/TablePro/Views/AIChat/AIChatToolUseBlockView.swift b/TablePro/Views/AIChat/AIChatToolUseBlockView.swift index f300e8011..cca75a2ec 100644 --- a/TablePro/Views/AIChat/AIChatToolUseBlockView.swift +++ b/TablePro/Views/AIChat/AIChatToolUseBlockView.swift @@ -33,9 +33,13 @@ struct AIChatToolUseBlockView: View { HStack(spacing: 4) { Text(callingLabel) .foregroundStyle(.secondary) + .fixedSize() Text(block.name) .fontWeight(.semibold) .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.middle) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) } .font(.caption) if hasInput && !isPending { diff --git a/TablePro/Views/AIChat/ChatComposerTextView.swift b/TablePro/Views/AIChat/ChatComposerTextView.swift index 8c8d5eb81..25c15c21b 100644 --- a/TablePro/Views/AIChat/ChatComposerTextView.swift +++ b/TablePro/Views/AIChat/ChatComposerTextView.swift @@ -40,6 +40,11 @@ struct ChatComposerTextView: NSViewRepresentable { textView.textContainer?.widthTracksTextView = true textView.isHorizontallyResizable = false textView.isVerticallyResizable = true + textView.minSize = NSSize(width: 0, height: 0) + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) textView.autoresizingMask = [.width] textView.placeholder = placeholder textView.acceptsImagePaste = acceptsImages @@ -48,12 +53,14 @@ struct ChatComposerTextView: NSViewRepresentable { let scrollView = ChatComposerScrollView() scrollView.documentView = textView scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false scrollView.autohidesScrollers = true scrollView.scrollerStyle = .overlay scrollView.borderType = .noBorder scrollView.drawsBackground = false scrollView.backgroundColor = .clear scrollView.verticalScrollElasticity = .allowed + scrollView.horizontalScrollElasticity = .none scrollView.minLines = minLines scrollView.maxLines = maxLines @@ -100,6 +107,7 @@ struct ChatComposerTextView: NSViewRepresentable { } } + scrollView.syncDocumentWidthToBounds() scrollView.invalidateIntrinsicContentSize() } @@ -266,6 +274,16 @@ final class ChatComposerScrollView: NSScrollView { var minLines: Int = 1 var maxLines: Int = 5 + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + syncDocumentWidthToBounds() + } + + override func layout() { + super.layout() + syncDocumentWidthToBounds() + } + override var intrinsicContentSize: NSSize { guard let textView = documentView as? NSTextView, @@ -280,8 +298,26 @@ final class ChatComposerScrollView: NSScrollView { let verticalPadding = inset.height * 2 let minHeight = CGFloat(minLines) * lineHeight + verticalPadding let maxHeight = CGFloat(maxLines) * lineHeight + verticalPadding + layoutManager.ensureLayout(for: container) let used = layoutManager.usedRect(for: container).height let content = used + verticalPadding return NSSize(width: NSView.noIntrinsicMetric, height: max(minHeight, min(maxHeight, content))) } + + func syncDocumentWidthToBounds() { + guard let textView = documentView as? NSTextView else { return } + let width = max(0, bounds.width) + guard width > 0 else { return } + + if abs(textView.frame.width - width) > 0.5 { + textView.setFrameSize(NSSize(width: width, height: max(textView.frame.height, 1))) + } + + guard let container = textView.textContainer else { return } + let nextSize = NSSize(width: width, height: CGFloat.greatestFiniteMagnitude) + guard abs(container.containerSize.width - width) > 0.5 else { return } + container.containerSize = nextSize + textView.layoutManager?.ensureLayout(for: container) + invalidateIntrinsicContentSize() + } } diff --git a/TablePro/Views/AIChat/MarkdownView.swift b/TablePro/Views/AIChat/MarkdownView.swift index 15927b02c..e04362d41 100644 --- a/TablePro/Views/AIChat/MarkdownView.swift +++ b/TablePro/Views/AIChat/MarkdownView.swift @@ -23,8 +23,10 @@ struct MarkdownView: View { ForEach(blocks) { block in MarkdownBlockView(block: block, prefersLightweightCode: isStreaming) .equatable() + .frame(maxWidth: .infinity, alignment: .leading) } } + .frame(maxWidth: .infinity, alignment: .leading) } } @@ -176,29 +178,32 @@ private struct MarkdownTableView: View { var body: some View { let columnCount = headers.count - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { - GridRow { - ForEach(0.. Date: Sat, 25 Jul 2026 19:13:29 +0700 Subject: [PATCH 2/2] refactor(ai-chat): compress the panel with native layout instead of clipping --- CHANGELOG.md | 4 + .../Views/AIChat/AIChatCodeBlockView.swift | 3 - TablePro/Views/AIChat/AIChatMessageView.swift | 2 - TablePro/Views/AIChat/AIChatPanelView.swift | 13 +-- .../Views/AIChat/AIChatToolUseBlockView.swift | 1 - .../Views/AIChat/ChatComposerTextView.swift | 108 ++++++++---------- TablePro/Views/AIChat/MarkdownView.swift | 16 ++- .../RightSidebar/UnifiedRightPanelView.swift | 2 - .../AIChat/ChatComposerScrollViewTests.swift | 83 ++++++++++++++ 9 files changed, 148 insertions(+), 84 deletions(-) create mode 100644 TableProTests/Views/AIChat/ChatComposerScrollViewTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a9e36a7c..853e2cc97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- The AI chat panel stays inside the right panel when you drag it narrow. The model name, tool names, and code block headers now truncate, long chat messages and code wrap, wide tables scroll inside their own box, and the composer text follows the panel width instead of running under the editor. (#1956) + ## [0.60.1] - 2026-07-25 ### Added diff --git a/TablePro/Views/AIChat/AIChatCodeBlockView.swift b/TablePro/Views/AIChat/AIChatCodeBlockView.swift index d6b4d11c0..ec38c8180 100644 --- a/TablePro/Views/AIChat/AIChatCodeBlockView.swift +++ b/TablePro/Views/AIChat/AIChatCodeBlockView.swift @@ -60,12 +60,10 @@ struct AIChatCodeBlockView: View, Equatable { .fontWeight(.medium) .foregroundStyle(.secondary) .lineLimit(1) - .truncationMode(.middle) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color(nsColor: .separatorColor)) .clipShape(RoundedRectangle(cornerRadius: 4)) - .layoutPriority(-1) } Spacer(minLength: 0) @@ -128,7 +126,6 @@ struct AIChatCodeBlockView: View, Equatable { ) .frame(maxWidth: .infinity) .frame(height: editorHeight) - .clipped() } } diff --git a/TablePro/Views/AIChat/AIChatMessageView.swift b/TablePro/Views/AIChat/AIChatMessageView.swift index 6f04a99f0..e08798071 100644 --- a/TablePro/Views/AIChat/AIChatMessageView.swift +++ b/TablePro/Views/AIChat/AIChatMessageView.swift @@ -60,7 +60,6 @@ struct AIChatMessageView: View { } } .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) .background(Color.accentColor.opacity(Self.userBubbleTintOpacity)) .clipShape(RoundedRectangle(cornerRadius: 8)) } else { @@ -153,7 +152,6 @@ struct AIChatMessageView: View { VStack(alignment: .leading, spacing: 6) { ForEach(visibleBlocks) { block in AIChatBlockView(block: block) - .frame(maxWidth: .infinity, alignment: .leading) } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index 123fc04b8..2269833f3 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -43,8 +43,6 @@ struct AIChatPanelView: View { inputArea } } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .clipped() .onAppear { viewModel.connection = connection } @@ -129,7 +127,6 @@ struct AIChatPanelView: View { ? { viewModel.editMessage(message) } : nil ) .padding(.vertical, 4) - .frame(maxWidth: .infinity, alignment: .leading) .id(message.id) } } @@ -178,7 +175,6 @@ struct AIChatPanelView: View { .accessibilityLabel(String(localized: "Scroll to latest message")) } } - .frame(maxWidth: .infinity, maxHeight: .infinity) } // MARK: - Error Banner @@ -254,12 +250,10 @@ struct AIChatPanelView: View { slashCommandMenu modeMenu modelPicker - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) sendOrStopButton } } .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) } } @@ -299,7 +293,6 @@ struct AIChatPanelView: View { Image(systemName: settingsManager.ai.chatMode.symbolName) Text(settingsManager.ai.chatMode.displayName) .lineLimit(1) - .truncationMode(.tail) Image(systemName: "chevron.up.chevron.down") .font(.caption2) } @@ -307,6 +300,7 @@ struct AIChatPanelView: View { .foregroundStyle(.secondary) } .menuStyle(.borderlessButton) + .fixedSize() .help(settingsManager.ai.chatMode.helpText) } @@ -366,13 +360,12 @@ struct AIChatPanelView: View { Text(label) .lineLimit(1) .truncationMode(.middle) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) Image(systemName: "chevron.up.chevron.down") .font(.caption2) } .font(.caption) .foregroundStyle(.secondary) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) } .menuStyle(.borderlessButton) .help(String(localized: "Choose AI provider and model")) @@ -427,6 +420,7 @@ struct AIChatPanelView: View { .foregroundStyle(.secondary) } .menuStyle(.borderlessButton) + .fixedSize() .help(String(localized: "Attach context")) .accessibilityLabel(String(localized: "Attach context")) } @@ -466,6 +460,7 @@ struct AIChatPanelView: View { .foregroundStyle(.secondary) } .menuStyle(.borderlessButton) + .fixedSize() .help(String(localized: "Slash commands")) .accessibilityLabel(String(localized: "Slash commands")) } diff --git a/TablePro/Views/AIChat/AIChatToolUseBlockView.swift b/TablePro/Views/AIChat/AIChatToolUseBlockView.swift index cca75a2ec..d78f10956 100644 --- a/TablePro/Views/AIChat/AIChatToolUseBlockView.swift +++ b/TablePro/Views/AIChat/AIChatToolUseBlockView.swift @@ -39,7 +39,6 @@ struct AIChatToolUseBlockView: View { .foregroundStyle(.primary) .lineLimit(1) .truncationMode(.middle) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) } .font(.caption) if hasInput && !isPending { diff --git a/TablePro/Views/AIChat/ChatComposerTextView.swift b/TablePro/Views/AIChat/ChatComposerTextView.swift index 25c15c21b..03abbc4ec 100644 --- a/TablePro/Views/AIChat/ChatComposerTextView.swift +++ b/TablePro/Views/AIChat/ChatComposerTextView.swift @@ -24,43 +24,13 @@ struct ChatComposerTextView: NSViewRepresentable { let onPasteImageData: (Data, String) -> Void func makeNSView(context: Context) -> ChatComposerScrollView { - let textView = ChatComposerNSTextView() + let textView = ChatComposerNSTextView.make() textView.delegate = context.coordinator - textView.isRichText = false - textView.isEditable = true - textView.isSelectable = true - textView.allowsUndo = true - textView.drawsBackground = false - textView.backgroundColor = .clear - textView.font = .systemFont(ofSize: NSFont.systemFontSize) - textView.textColor = .labelColor - textView.insertionPointColor = .controlAccentColor - textView.textContainerInset = NSSize(width: 14, height: 8) - textView.textContainer?.lineFragmentPadding = 0 - textView.textContainer?.widthTracksTextView = true - textView.isHorizontallyResizable = false - textView.isVerticallyResizable = true - textView.minSize = NSSize(width: 0, height: 0) - textView.maxSize = NSSize( - width: CGFloat.greatestFiniteMagnitude, - height: CGFloat.greatestFiniteMagnitude - ) - textView.autoresizingMask = [.width] textView.placeholder = placeholder textView.acceptsImagePaste = acceptsImages textView.onPasteImageData = onPasteImageData - let scrollView = ChatComposerScrollView() - scrollView.documentView = textView - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = false - scrollView.autohidesScrollers = true - scrollView.scrollerStyle = .overlay - scrollView.borderType = .noBorder - scrollView.drawsBackground = false - scrollView.backgroundColor = .clear - scrollView.verticalScrollElasticity = .allowed - scrollView.horizontalScrollElasticity = .none + let scrollView = ChatComposerScrollView.make(documentView: textView) scrollView.minLines = minLines scrollView.maxLines = maxLines @@ -107,7 +77,6 @@ struct ChatComposerTextView: NSViewRepresentable { } } - scrollView.syncDocumentWidthToBounds() scrollView.invalidateIntrinsicContentSize() } @@ -216,6 +185,22 @@ final class ChatComposerNSTextView: NSTextView { var acceptsImagePaste: Bool = false var onPasteImageData: ((Data, String) -> Void)? + static func make() -> ChatComposerNSTextView { + let textView = ChatComposerNSTextView() + textView.isRichText = false + textView.isEditable = true + textView.isSelectable = true + textView.allowsUndo = true + textView.drawsBackground = false + textView.backgroundColor = .clear + textView.font = .systemFont(ofSize: NSFont.systemFontSize) + textView.textColor = .labelColor + textView.insertionPointColor = .controlAccentColor + textView.textContainerInset = NSSize(width: 14, height: 8) + textView.textContainer?.lineFragmentPadding = 0 + return textView + } + override func becomeFirstResponder() -> Bool { let became = super.becomeFirstResponder() if became { onFocusChange?(true) } @@ -274,14 +259,35 @@ final class ChatComposerScrollView: NSScrollView { var minLines: Int = 1 var maxLines: Int = 5 - override func setFrameSize(_ newSize: NSSize) { - super.setFrameSize(newSize) - syncDocumentWidthToBounds() - } + static func make(documentView textView: NSTextView) -> ChatComposerScrollView { + let scrollView = ChatComposerScrollView() + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.scrollerStyle = .overlay + scrollView.borderType = .noBorder + scrollView.drawsBackground = false + scrollView.backgroundColor = .clear + scrollView.verticalScrollElasticity = .allowed + scrollView.horizontalScrollElasticity = .none + scrollView.documentView = textView - override func layout() { - super.layout() - syncDocumentWidthToBounds() + let contentSize = scrollView.contentSize + textView.frame = NSRect(origin: .zero, size: contentSize) + textView.minSize = NSSize(width: 0, height: 0) + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.autoresizingMask = [.width] + textView.textContainer?.size = NSSize( + width: contentSize.width, + height: CGFloat.greatestFiniteMagnitude + ) + textView.textContainer?.widthTracksTextView = true + return scrollView } override var intrinsicContentSize: NSSize { @@ -298,26 +304,12 @@ final class ChatComposerScrollView: NSScrollView { let verticalPadding = inset.height * 2 let minHeight = CGFloat(minLines) * lineHeight + verticalPadding let maxHeight = CGFloat(maxLines) * lineHeight + verticalPadding - layoutManager.ensureLayout(for: container) + layoutManager.ensureLayout( + forBoundingRect: NSRect(x: 0, y: 0, width: container.size.width, height: maxHeight), + in: container + ) let used = layoutManager.usedRect(for: container).height let content = used + verticalPadding return NSSize(width: NSView.noIntrinsicMetric, height: max(minHeight, min(maxHeight, content))) } - - func syncDocumentWidthToBounds() { - guard let textView = documentView as? NSTextView else { return } - let width = max(0, bounds.width) - guard width > 0 else { return } - - if abs(textView.frame.width - width) > 0.5 { - textView.setFrameSize(NSSize(width: width, height: max(textView.frame.height, 1))) - } - - guard let container = textView.textContainer else { return } - let nextSize = NSSize(width: width, height: CGFloat.greatestFiniteMagnitude) - guard abs(container.containerSize.width - width) > 0.5 else { return } - container.containerSize = nextSize - textView.layoutManager?.ensureLayout(for: container) - invalidateIntrinsicContentSize() - } } diff --git a/TablePro/Views/AIChat/MarkdownView.swift b/TablePro/Views/AIChat/MarkdownView.swift index e04362d41..3f673ac9c 100644 --- a/TablePro/Views/AIChat/MarkdownView.swift +++ b/TablePro/Views/AIChat/MarkdownView.swift @@ -23,7 +23,6 @@ struct MarkdownView: View { ForEach(blocks) { block in MarkdownBlockView(block: block, prefersLightweightCode: isStreaming) .equatable() - .frame(maxWidth: .infinity, alignment: .leading) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -178,7 +177,7 @@ private struct MarkdownTableView: View { var body: some View { let columnCount = headers.count - ScrollView(.horizontal, showsIndicators: true) { + ScrollView(.horizontal) { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { GridRow { ForEach(0.. ChatComposerScrollView { + let textView = ChatComposerNSTextView.make() + let scrollView = ChatComposerScrollView.make(documentView: textView) + scrollView.frame = NSRect(x: 0, y: 0, width: width, height: height) + scrollView.layoutSubtreeIfNeeded() + return scrollView + } + + @Test("The text view width follows the scroll view width") + func documentWidthFollowsScrollView() throws { + let scrollView = makeComposer(width: 420) + let textView = try #require(scrollView.documentView as? NSTextView) + #expect(abs(textView.frame.width - scrollView.contentSize.width) < 0.5) + + scrollView.setFrameSize(NSSize(width: 240, height: 40)) + scrollView.layoutSubtreeIfNeeded() + #expect(abs(textView.frame.width - scrollView.contentSize.width) < 0.5) + } + + @Test("The text container stays inside the text view") + func containerTracksTextViewWidth() throws { + let scrollView = makeComposer(width: 300) + let textView = try #require(scrollView.documentView as? NSTextView) + let container = try #require(textView.textContainer) + #expect(container.containerSize.width > 0) + #expect(container.containerSize.width <= textView.frame.width) + } + + @Test("Long text wraps inside the composer instead of widening it") + func longTextWraps() throws { + let scrollView = makeComposer(width: 260) + let textView = try #require(scrollView.documentView as? NSTextView) + let container = try #require(textView.textContainer) + let layoutManager = try #require(textView.layoutManager) + + textView.string = String(repeating: "select * from users where id = 1 ", count: 12) + scrollView.layoutSubtreeIfNeeded() + + #expect(abs(textView.frame.width - scrollView.contentSize.width) < 0.5) + #expect(layoutManager.usedRect(for: container).width <= container.containerSize.width + 0.5) + } + + @Test("Height grows with content and clamps to maxLines") + func heightClampsBetweenMinAndMaxLines() throws { + let scrollView = makeComposer(width: 320) + let textView = try #require(scrollView.documentView as? NSTextView) + scrollView.minLines = 1 + scrollView.maxLines = 5 + + let empty = scrollView.intrinsicContentSize.height + + textView.string = "one\ntwo\nthree" + let threeLines = scrollView.intrinsicContentSize.height + + textView.string = String(repeating: "line\n", count: 40) + let clamped = scrollView.intrinsicContentSize.height + + textView.string = String(repeating: "line\n", count: 200) + let stillClamped = scrollView.intrinsicContentSize.height + + #expect(empty < threeLines) + #expect(threeLines < clamped) + #expect(clamped == stillClamped) + } + + @Test("Width carries no intrinsic metric so the host drives it") + func widthIsDrivenByTheHost() { + let scrollView = makeComposer(width: 320) + #expect(scrollView.intrinsicContentSize.width == NSView.noIntrinsicMetric) + } +}