From f6dff26efb7191b045bfc3c8323879aa46b2e5fb Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 10:36:26 -0700 Subject: [PATCH 1/3] Artifactory composer: focus the field that exists, not the one about to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: clicking + in the rail's ARTIFACTORY section opened the composer but never took the keyboard, and Post stayed dead. One cause, both symptoms. The + set `isComposing = true` and `draftFocused = true` in the same transaction, but a `@FocusState` write only lands on a field already in the view tree — the TextField did not exist yet, so the write was dropped. With the rail unfocused every keystroke went to the terminal beside it, which is why the draft stayed empty; `canPost` is derived from the draft, so Post was correctly disabled the whole time. It was not a second bug. Not the terminal's doing, which was the obvious suspect: `MountFocusPolicy` and `ScrollFocusPolicy` both return false for `.textInput`, so a surface will not take the keyboard back off a field that holds it. Getting it there was the only problem. - Focus is set from the composer's own `onAppear`, one run-loop hop later, so the field exists when the write lands. - + on an already-open composer re-focuses it instead of doing nothing. - The composer moves out of the ScrollView and is pinned beneath it. Inside, it was one more row in a list that can be taller than the rail: it could open scrolled out of sight and drifted under the pointer as posts arrived. Suite: 1482 tests / 153 suites pass; swiftlint 0 errors, swift-format --strict clean. The focus behaviour itself is not unit-testable and this environment has no GUI automation, so it needs a human to click + once and confirm the caret lands in the field. Signed-off-by: scgopi Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019A6NULwEiBXEdRXwEKKcRH --- .../LoopWorkspace/ArtifactorySection.swift | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift b/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift index c6ff3b7c..d0c343d2 100644 --- a/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift +++ b/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift @@ -86,13 +86,17 @@ struct ArtifactorySection: View { if post.id == firstUnreadID { sinceYouLooked } postRow(post) } - if isComposing { composer } } .frame(maxWidth: .infinity, alignment: .leading) } .scrollBounceBehavior(.basedOnSize) .defaultScrollAnchor(.bottom) .frame(minHeight: 90, maxHeight: .infinity) + // Outside the scroll view on purpose. Inside it the composer was one more row + // in a list that can be taller than the rail — it could open scrolled out of + // sight, and it moved under the pointer as posts arrived. Pinned here it is + // always the thing directly above the section's rule while you are writing. + if isComposing { composer } } Rectangle().fill(.white.opacity(0.07)).frame(height: 1) } @@ -123,8 +127,13 @@ struct ArtifactorySection: View { } if !isFolded { Button { - isComposing = true - draftFocused = true + // Already open: the composer will not appear again, so this is the write that + // puts the keyboard back in it. Opening it is `onAppear`'s job. + if isComposing { + draftFocused = true + } else { + isComposing = true + } } label: { Image(systemName: "plus") .font(.system(size: 9, weight: .semibold)) @@ -328,6 +337,15 @@ struct ArtifactorySection: View { RoundedRectangle(cornerRadius: 9) .stroke(Theme.paneFocusTint.opacity(0.45), lineWidth: 1) } + // A `@FocusState` write only lands on a field that is *already* in the view tree. + // Setting it in the same transaction that creates the composer — which is what the + // + button used to do — is dropped on the floor, and with the rail unfocused every + // keystroke goes to the terminal instead: the draft stays empty, so Post stays + // disabled and the section looks like it does nothing. `onAppear` is a transaction + // too, hence the hop: the field exists by the time this runs. + .onAppear { + DispatchQueue.main.async { draftFocused = true } + } } private var trimmedDraft: String { From 52b5a239262d913cf58d09440db75f08eaef7e52 Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 11:00:01 -0700 Subject: [PATCH 2/3] Artifactory composer: a named button, not a + nobody can find MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: the + icon is too hard to identify. It is — and the pattern was borrowed wrongly. Every other hover-revealed + in the app (the sidebar's rows, the canvas's add button) is a *second* way to do something reachable elsewhere, so hiding it until the pointer arrives costs nothing. This one is the only way to put a human's note on the board without leaving for a terminal, and an affordance you have to hover to discover is one nobody discovers. So: no icon-only control. A full-width "Leave a note" button, always drawn, modelled on the geometry `LoopSummarySection` already uses for "Answer it" (24pt, radius 5) but in quiet ink rather than a filled call to action — it is a standing affordance, not an urgent ask. It sits at the foot of the section, exactly where the composer opens, so the click and its result are in the same place. "Leave a note" rather than "New Human Input": it is the vocabulary the feature already speaks — `artifactory post `, "a note for whoever comes next" — and it is verb-first like "Answer it". That the author is a human is already said inside the composer, where it is a fact about the post rather than a name for the button. Suite: 1482 tests / 153 suites pass; swiftlint 0 errors, format clean. Still needs a human to look at it — no GUI automation here. Signed-off-by: scgopi Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019A6NULwEiBXEdRXwEKKcRH --- .../LoopWorkspace/ArtifactorySection.swift | 53 ++++++++++++------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift b/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift index d0c343d2..ce24ffbd 100644 --- a/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift +++ b/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift @@ -96,7 +96,11 @@ struct ArtifactorySection: View { // in a list that can be taller than the rail — it could open scrolled out of // sight, and it moved under the pointer as posts arrived. Pinned here it is // always the thing directly above the section's rule while you are writing. - if isComposing { composer } + if isComposing { + composer + } else { + leaveANoteButton + } } Rectangle().fill(.white.opacity(0.07)).frame(height: 1) } @@ -125,24 +129,6 @@ struct ArtifactorySection: View { .background( Theme.paneFocusTint.opacity(0.22), in: RoundedRectangle(cornerRadius: 3)) } - if !isFolded { - Button { - // Already open: the composer will not appear again, so this is the write that - // puts the keyboard back in it. Opening it is `onAppear`'s job. - if isComposing { - draftFocused = true - } else { - isComposing = true - } - } label: { - Image(systemName: "plus") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(.white.opacity(0.62)) - .frame(width: 14, height: 14) - } - .buttonStyle(.plain) - .help("Leave a note on the board") - } Image(systemName: isFolded ? "chevron.down" : "chevron.up") .font(.system(size: 8, weight: .semibold)) .foregroundStyle(.white.opacity(0.6)) @@ -173,6 +159,35 @@ struct ArtifactorySection: View { .onTapGesture(perform: onToggleFold) } + /// The one place a human speaks to the graph, and it says so in words. + /// + /// This was a `+` on the header, drawn only under the pointer — the manners every + /// other header control in the app has, and wrong here. Those are all *second* ways + /// to do something reachable elsewhere; this is the only way to put a human's note on + /// the board without leaving for a terminal, and an affordance you have to hover to + /// discover is one nobody discovers. Named, always drawn, and sitting exactly where + /// the composer opens, so the click and its result are in the same place. + private var leaveANoteButton: some View { + Button { + isComposing = true + } label: { + HStack(spacing: 5) { + Image(systemName: "square.and.pencil") + .font(.system(size: 10, weight: .medium)) + Text("Leave a note") + .font(.system(size: 11, weight: .semibold)) + } + .foregroundStyle(.white.opacity(0.62)) + .frame(maxWidth: .infinity, minHeight: 24) + .background(.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 5)) + .overlay { + RoundedRectangle(cornerRadius: 5).stroke(.white.opacity(0.08), lineWidth: 1) + } + } + .buttonStyle(.plain) + .help("Post a note to this project's board, as a human") + } + private var sinceYouLooked: some View { HStack(spacing: 7) { Text("SINCE YOU LOOKED") From 878eff55b2945163fe7cbd50ad759125dab8a7cf Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 11:03:54 -0700 Subject: [PATCH 3/3] Artifactory: the board hugs its posts instead of the rail's spare space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: a big gap opens between the ARTIFACTORY heading and the "N message records" line below it when the rail has little content. Mine, and visible in the frame I wrote: `.frame(minHeight: 90, maxHeight: .infinity)`. A ScrollView is greedy, so offered the rail's whole slack it took it; `defaultScrollAnchor(.bottom)` then pinned the posts to the bottom of that box while the header stayed at its top. A board with two notes on it drew a header, a stretch of nothing, and then the notes — and the emptier the rail, the worse it got. The box now measures its posts through a preference and asks for exactly that height, capped at 300pt (about four posts) before it starts scrolling. With no slack inside the box there is nothing for the anchor to spread, so the gap cannot come back rather than merely being smaller — and the bottom anchor still does its real job the moment the posts overflow. Suite: 1482 tests / 153 suites pass; swiftlint 0 errors, format clean. Needs a human to look at it — no GUI automation here. Signed-off-by: scgopi Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019A6NULwEiBXEdRXwEKKcRH --- .../LoopWorkspace/ArtifactorySection.swift | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift b/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift index ce24ffbd..b71618f3 100644 --- a/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift +++ b/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift @@ -2,6 +2,13 @@ import ArtifactoryKit import GraphcodeKit import SwiftUI +/// The measured height of the board's posts, reported up so the scroll box can size +/// itself to them instead of to whatever space the rail happens to have spare. +private struct ArtifactoryContentHeight: PreferenceKey { + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } +} + /// What the rail needs to know about the board without building a view to find out. enum ArtifactoryPresentation { /// Whether this graph's board has anything to show. Absent while empty, for the @@ -54,6 +61,13 @@ struct ArtifactorySection: View { /// Whether the mirrored records are unfolded. Local and unpersisted, unlike the /// section's own fold: opening the receipts is a thing you do once to answer a /// question, not a way you prefer to read the board. + /// How tall the posts actually are, so the scroll box can ask for exactly that. + @State private var contentHeight: CGFloat = 0 + /// Past this the board scrolls rather than pushing the sections under it off the rail. + /// Roughly four posts at the rail's default width — enough that scrolling is the + /// exception, not the way the section is normally read. + static let maxScrollHeight: CGFloat = 300 + @State private var showsRecords = false @State private var isComposing = false @State private var draft = "" @@ -88,10 +102,23 @@ struct ArtifactorySection: View { } } .frame(maxWidth: .infinity, alignment: .leading) + .background( + GeometryReader { proxy in + Color.clear.preference(key: ArtifactoryContentHeight.self, value: proxy.size.height) + } + ) } + .onPreferenceChange(ArtifactoryContentHeight.self) { contentHeight = $0 } .scrollBounceBehavior(.basedOnSize) .defaultScrollAnchor(.bottom) - .frame(minHeight: 90, maxHeight: .infinity) + // Hug the posts, and only then scroll. A `ScrollView` is greedy: given + // `maxHeight: .infinity` it took the rail's whole slack, and `defaultScrollAnchor` + // pinned the posts to the bottom of that box while the header stayed at its top — + // so a board with two notes on it drew a header, a stretch of nothing, and then + // the notes. Measuring the content and asking for exactly that height (up to a + // cap) leaves no slack for the anchor to spread, which is why the gap cannot come + // back rather than merely being smaller. + .frame(height: min(contentHeight, Self.maxScrollHeight)) // Outside the scroll view on purpose. Inside it the composer was one more row // in a list that can be taller than the rail — it could open scrolled out of // sight, and it moved under the pointer as posts arrived. Pinned here it is