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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions TablePro/Views/AIChat/AIChatCodeBlockView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -80,19 +81,23 @@ struct AIChatCodeBlockView: View, Equatable {
systemImage: isCopied ? "checkmark" : "doc.on.doc"
)
.font(.caption2)
.lineLimit(1)
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.fixedSize()

if isInsertable {
Button {
actions?.insertQueryFromAI(code)
} 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")
Expand All @@ -119,6 +124,7 @@ struct AIChatCodeBlockView: View, Equatable {
configuration: Self.makeConfiguration(),
state: $editorState
)
.frame(maxWidth: .infinity)
.frame(height: editorHeight)
}
}
Expand Down Expand Up @@ -214,6 +220,7 @@ private struct CodeBlockGroupBoxStyle: GroupBoxStyle {

configuration.content
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
Expand Down
5 changes: 3 additions & 2 deletions TablePro/Views/AIChat/AIChatMessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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("·")
Expand All @@ -47,7 +47,7 @@ struct AIChatMessageView: View {

if let onEdit {
HStack {
Spacer()
Spacer(minLength: 0)
Button { onEdit() } label: {
Image(systemName: "pencil")
.font(.caption2)
Expand Down Expand Up @@ -154,6 +154,7 @@ struct AIChatMessageView: View {
AIChatBlockView(block: block)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 6)
}
}
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Views/AIChat/AIChatPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ struct AIChatPanelView: View {
.id(message.id)
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 8)
.padding(.vertical, 8)
.scrollTargetLayout()
Expand Down Expand Up @@ -249,7 +250,6 @@ struct AIChatPanelView: View {
slashCommandMenu
modeMenu
modelPicker
Spacer()
sendOrStopButton
}
}
Expand Down Expand Up @@ -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"))
}
}
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Views/AIChat/AIChatToolUseBlockView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
78 changes: 53 additions & 25 deletions TablePro/Views/AIChat/ChatComposerTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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,
Expand All @@ -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)))
Expand Down
25 changes: 14 additions & 11 deletions TablePro/Views/AIChat/MarkdownView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ struct MarkdownView: View {
.equatable()
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}

Expand Down Expand Up @@ -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..<columnCount, id: \.self) { col in
cell(text: headers[col], alignment: alignments[safe: col] ?? .left)
.fontWeight(.semibold)
}
}
Divider().gridCellColumns(columnCount)
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
ScrollView(.horizontal) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) {
GridRow {
ForEach(0..<columnCount, id: \.self) { col in
cell(text: row[safe: col] ?? "", alignment: alignments[safe: col] ?? .left)
cell(text: headers[col], alignment: alignments[safe: col] ?? .left)
.fontWeight(.semibold)
}
}
Divider().gridCellColumns(columnCount)
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
GridRow {
ForEach(0..<columnCount, id: \.self) { col in
cell(text: row[safe: col] ?? "", alignment: alignments[safe: col] ?? .left)
}
}
}
}
.padding(8)
}
.padding(8)
.background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
Expand Down
1 change: 1 addition & 0 deletions TablePro/Views/RightSidebar/UnifiedRightPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ struct UnifiedRightPanelView: View {
case .aiChat: aiChatView
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}

private var inspectorHeader: some View {
Expand Down
83 changes: 83 additions & 0 deletions TableProTests/Views/AIChat/ChatComposerScrollViewTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//
// ChatComposerScrollViewTests.swift
// TableProTests
//

import AppKit
@testable import TablePro
import Testing

@MainActor
@Suite("ChatComposerScrollView layout")
struct ChatComposerScrollViewTests {
private func makeComposer(width: CGFloat, height: CGFloat = 40) -> 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)
}
}
Loading