Skip to content

Artifactory composer: focus the field that exists, not the one about to - #235

Merged
scgopi merged 3 commits into
mainfrom
fix/artifactory-composer-focus
Sep 1, 2026
Merged

Artifactory composer: focus the field that exists, not the one about to#235
scgopi merged 3 commits into
mainfrom
fix/artifactory-composer-focus

Conversation

@scgopi

@scgopi scgopi commented Sep 1, 2026

Copy link
Copy Markdown
Owner

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. 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 never 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.

Changes

  • 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 it drifted under the pointer as posts arrived.

Verification

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 permissions, so it needs a human to click + once and confirm the caret lands in the field.

🤖 Generated with Claude Code

https://claude.ai/code/session_019A6NULwEiBXEdRXwEKKcRH

scgopi and others added 3 commits September 1, 2026 10:36
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
scgopi merged commit fb682d6 into main Sep 1, 2026
1 check passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant