From d86d2ae239e9bfd451ec0df2139ab267ccbc8f90 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sat, 3 Oct 2026 16:19:24 -0700 Subject: [PATCH 1/2] Nod composer: keep the whole draft in view after the pane narrows sizeThatFits measured by resizing the live text container. SwiftUI also asks for sizes it does not use, and after the pane narrowed the box was left one line tall while the draft wrapped to four, so only its last line showed. It now measures on a scratch layout and ignores an unbounded proposed width. Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- .../Features/Nod/NodComposerView.swift | 27 ++++++++++---- graphcode/Tests/NodComposerHeightTests.swift | 35 +++++++++++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/graphcode/Sources/Features/Nod/NodComposerView.swift b/graphcode/Sources/Features/Nod/NodComposerView.swift index 4a130d10..f5e6e1b2 100644 --- a/graphcode/Sources/Features/Nod/NodComposerView.swift +++ b/graphcode/Sources/Features/Nod/NodComposerView.swift @@ -418,16 +418,29 @@ struct NodComposerTextView: NSViewRepresentable { func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSScrollView, context: Context) -> CGSize? { - guard let textView = nsView.documentView as? NSTextView, - let container = textView.textContainer, let manager = textView.layoutManager - else { return nil } - let width = proposal.width ?? max(nsView.frame.width, 1) - container.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude) - manager.ensureLayout(for: container) + guard let textView = nsView.documentView as? NSTextView else { return nil } + let proposed = proposal.width.flatMap { $0.isFinite && $0 > 0 ? $0 : nil } + let width = proposed ?? max(nsView.frame.width, 1) return CGSize( width: width, height: Self.height( - forUsedHeight: manager.usedRect(for: container).height, lineHeight: Self.lineHeight)) + forUsedHeight: Self.usedHeight(of: textView.string, width: width), + lineHeight: Self.lineHeight)) + } + + /// Measured on a scratch layout, never the live one: SwiftUI asks for sizes it will not + /// use, and resizing the live container for one of those left the box a line tall while + /// the text wrapped to four, so narrowing the pane cut the draft off. + static func usedHeight(of text: String, width: CGFloat) -> CGFloat { + let storage = NSTextStorage(string: text, attributes: [.font: font]) + let manager = NSLayoutManager() + let container = NSTextContainer( + size: NSSize(width: width, height: .greatestFiniteMagnitude)) + container.lineFragmentPadding = 0 + manager.addTextContainer(container) + storage.addLayoutManager(manager) + manager.ensureLayout(for: container) + return manager.usedRect(for: container).height } final class Coordinator: NSObject, NSTextViewDelegate { diff --git a/graphcode/Tests/NodComposerHeightTests.swift b/graphcode/Tests/NodComposerHeightTests.swift index 4ab010d4..31ca01b7 100644 --- a/graphcode/Tests/NodComposerHeightTests.swift +++ b/graphcode/Tests/NodComposerHeightTests.swift @@ -125,6 +125,41 @@ struct NodComposerHeightTests { #expect(visible(box).height >= line * 3 - 1) } + /// Narrowing the pane after typing left the box one line tall while the draft wrapped + /// to four: only its last line showed, cut off from the rest. + @Test(arguments: [(520, 360), (760, 360)]) + func aDraftStillFitsAfterThePaneNarrows(from: CGFloat, to: CGFloat) throws { + let store = Store( + initialState: NodChatFeature.State( + nodeID: UUID(), stateDirectory: URL(fileURLWithPath: "/tmp/nod-composer"), + loopTitle: "Composer", loopType: .sketch, goal: nil) + ) { NodChatFeature() } + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: from, height: 300), styleMask: [.titled], + backing: .buffered, defer: false) + let host = NSHostingView(rootView: AnyView(NodComposerView(store: store).frame(width: from))) + window.contentView = host + host.layoutSubtreeIfNeeded() + let box = try #require(Self.scrollView(in: host)) + let textView = try #require(box.documentView as? NSTextView) + window.makeFirstResponder(textView) + let draft = + "Explain why the goal loop keeps restarting after the predicate passes, and check " + + "whether the presence hook ever reports idle before the daemon samples it" + for character in draft { + textView.insertText(String(character), replacementRange: textView.selectedRange()) + settle(host) + } + + host.rootView = AnyView(NodComposerView(store: store).frame(width: to)) + window.setContentSize(NSSize(width: to, height: 300)) + for _ in 0..<5 { settle(host) } + + #expect(textView.frame.height > line * 2) + #expect(abs(box.frame.height - textView.frame.height) < 2) + #expect(visible(box).height >= textView.frame.height - 1) + } + @Test func aLongDraftShowsFiveLinesAndScrolls() throws { let (box, text) = try type((1...10).map { "line \($0)" }.joined(separator: "\n")) From 9ce6ae81afa40ce638af0a99d260ce23a777014f Mon Sep 17 00:00:00 2001 From: scgopi Date: Sat, 3 Oct 2026 16:23:12 -0700 Subject: [PATCH 2/2] Nod pane: use the graph canvas's background The chat pane was a lighter #1e1e22 than the near-black canvas beside it. It now uses Theme.canvasBackground, so the canvas and a Nod chat share one ground; cards and the composer keep their lighter fills on top. Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- graphcode/Sources/Features/Nod/NodStyle.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/graphcode/Sources/Features/Nod/NodStyle.swift b/graphcode/Sources/Features/Nod/NodStyle.swift index 4997a3cc..bdd475c4 100644 --- a/graphcode/Sources/Features/Nod/NodStyle.swift +++ b/graphcode/Sources/Features/Nod/NodStyle.swift @@ -4,7 +4,8 @@ import SwiftUI /// plain GraphCode chrome, action blue for actions, orange for "needs you", and muted text /// no dimmer than 55% white (review round 2). enum NodStyle { - static let paneBackground = Color(red: 0.118, green: 0.118, blue: 0.133) // #1e1e22 + /// The graph canvas's own tone, so moving between the canvas and a Nod chat keeps one ground. + static let paneBackground = Theme.canvasBackground static let cardBackground = Color(red: 0.098, green: 0.098, blue: 0.110) // #19191c static let composerBackground = Color(red: 0.149, green: 0.149, blue: 0.165) // #26262a static let bubble = Color.white.opacity(0.07)