Artifactory composer: focus the field that exists, not the one about to - #235
Merged
Conversation
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 <scgopireddy@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019A6NULwEiBXEdRXwEKKcRH
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 <note…>`, "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 <scgopireddy@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019A6NULwEiBXEdRXwEKKcRH
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 <scgopireddy@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019A6NULwEiBXEdRXwEKKcRH
scgopi
added a commit
that referenced
this pull request
Sep 1, 2026
…he rail (#236) Reported, after #235: "Leave a note" still does not take the keyboard, and the board sits at the top of the rail when it should be at the bottom. The focus fix in #235 was aimed at the wrong thing. It assumed `@FocusState` was being written too early; it was, but fixing the timing changed nothing, because the write can never land at all. While a loop is open Ghostty's `NSView` holds the window's first responder, and SwiftUI's focus system cannot take first responder off an AppKit view inside the same window — the caret never arrives, every keystroke goes to the terminal, the draft stays empty. The evidence was in the codebase the whole time: every text field that works in this app is in a sheet, `JumpPaletteView` focuses with a bare `onAppear` because a sheet's own window is key, and `AppView` says in as many words that ⌘K has to open over a terminal. The rail was documented read-only; I put the first inline field into it and found out why. - The composer is now a sheet — the same 420pt form the rename and workspace prompts use, with a Form, ⎋ to cancel, ⌘↵ to post, and the byte counter as the bound approaches. "Leave a note" in the rail opens it. - The section moves from above THIS LOOP to the foot of the rail, under the spacer, so slack collects above it rather than inside it. Low and against the footer is where a message board belongs — newest at the bottom, composer under it. Suite: 1482 tests / 153 suites pass; swiftlint 0 errors, format clean. Still needs a human to click once — no GUI automation here — but this time the mechanism is the one every other text entry in the app already relies on. Claude-Session: https://claude.ai/code/session_019A6NULwEiBXEdRXwEKKcRH Signed-off-by: scgopi <scgopireddy@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 = trueanddraftFocused = truein the same transaction. A@FocusStatewrite only lands on a field already in the view tree — theTextFielddid 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;canPostis derived from the draft, so Post was correctly disabled the whole time. It was never a second bug.Not the terminal's doing, which was the obvious suspect:
MountFocusPolicyandScrollFocusPolicyboth returnfalsefor.textInput, so a surface will not take the keyboard back off a field that holds it. Getting it there was the only problem.Changes
onAppear, one run-loop hop later, so the field exists when the write lands.ScrollViewand 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 it drifted under the pointer as posts arrived.Verification
Suite 1482 tests / 153 suites pass; swiftlint 0 errors,
swift-format --strictclean.🤖 Generated with Claude Code
https://claude.ai/code/session_019A6NULwEiBXEdRXwEKKcRH