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 8ddb2a3d4..ec38c8180 100644 --- a/TablePro/Views/AIChat/AIChatCodeBlockView.swift +++ b/TablePro/Views/AIChat/AIChatCodeBlockView.swift @@ -53,19 +53,20 @@ 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) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color(nsColor: .separatorColor)) .clipShape(RoundedRectangle(cornerRadius: 4)) } - Spacer() + Spacer(minLength: 0) Button { ClipboardService.shared.writeText(code) @@ -80,9 +81,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 +93,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,6 +124,7 @@ struct AIChatCodeBlockView: View, Equatable { configuration: Self.makeConfiguration(), state: $editorState ) + .frame(maxWidth: .infinity) .frame(height: editorHeight) } } @@ -214,6 +220,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..e08798071 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) @@ -154,6 +154,7 @@ struct AIChatMessageView: View { AIChatBlockView(block: block) } } + .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 6) } } diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index ed8f8f40a..2269833f3 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -130,6 +130,7 @@ struct AIChatPanelView: View { .id(message.id) } } + .frame(maxWidth: .infinity) .padding(.horizontal, 8) .padding(.vertical, 8) .scrollTargetLayout() @@ -249,7 +250,6 @@ struct AIChatPanelView: View { slashCommandMenu modeMenu modelPicker - Spacer() sendOrStopButton } } @@ -365,9 +365,9 @@ struct AIChatPanelView: View { } .font(.caption) .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) } .menuStyle(.borderlessButton) - .fixedSize() .help(String(localized: "Choose AI provider and model")) } } diff --git a/TablePro/Views/AIChat/AIChatToolUseBlockView.swift b/TablePro/Views/AIChat/AIChatToolUseBlockView.swift index f300e8011..d78f10956 100644 --- a/TablePro/Views/AIChat/AIChatToolUseBlockView.swift +++ b/TablePro/Views/AIChat/AIChatToolUseBlockView.swift @@ -33,9 +33,12 @@ struct AIChatToolUseBlockView: View { HStack(spacing: 4) { Text(callingLabel) .foregroundStyle(.secondary) + .fixedSize() Text(block.name) .fontWeight(.semibold) .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.middle) } .font(.caption) if hasInput && !isPending { diff --git a/TablePro/Views/AIChat/ChatComposerTextView.swift b/TablePro/Views/AIChat/ChatComposerTextView.swift index 8c8d5eb81..03abbc4ec 100644 --- a/TablePro/Views/AIChat/ChatComposerTextView.swift +++ b/TablePro/Views/AIChat/ChatComposerTextView.swift @@ -24,36 +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.autoresizingMask = [.width] textView.placeholder = placeholder textView.acceptsImagePaste = acceptsImages textView.onPasteImageData = onPasteImageData - let scrollView = ChatComposerScrollView() - scrollView.documentView = textView - scrollView.hasVerticalScroller = true - scrollView.autohidesScrollers = true - scrollView.scrollerStyle = .overlay - scrollView.borderType = .noBorder - scrollView.drawsBackground = false - scrollView.backgroundColor = .clear - scrollView.verticalScrollElasticity = .allowed + let scrollView = ChatComposerScrollView.make(documentView: textView) scrollView.minLines = minLines scrollView.maxLines = maxLines @@ -208,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) } @@ -266,6 +259,37 @@ final class ChatComposerScrollView: NSScrollView { var minLines: Int = 1 var maxLines: Int = 5 + 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 + + 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 { guard let textView = documentView as? NSTextView, @@ -280,6 +304,10 @@ final class ChatComposerScrollView: NSScrollView { let verticalPadding = inset.height * 2 let minHeight = CGFloat(minLines) * lineHeight + verticalPadding let maxHeight = CGFloat(maxLines) * lineHeight + verticalPadding + 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))) diff --git a/TablePro/Views/AIChat/MarkdownView.swift b/TablePro/Views/AIChat/MarkdownView.swift index 15927b02c..3f673ac9c 100644 --- a/TablePro/Views/AIChat/MarkdownView.swift +++ b/TablePro/Views/AIChat/MarkdownView.swift @@ -25,6 +25,7 @@ struct MarkdownView: View { .equatable() } } + .frame(maxWidth: .infinity, alignment: .leading) } } @@ -176,23 +177,25 @@ private struct MarkdownTableView: View { var body: some View { let columnCount = headers.count - 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) + } +}