From c51d2aa18c43aa5ffdb9978ef6d199a3463c7d42 Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 11:46:12 -0700 Subject: [PATCH] Artifactory: the composer is a sheet; the board sits at the foot of the rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Signed-off-by: scgopi Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019A6NULwEiBXEdRXwEKKcRH --- .../LoopWorkspace/ArtifactorySection.swift | 111 +++++++++--------- .../LoopWorkspace/LoopWorkspaceRail.swift | 21 ++-- 2 files changed, 71 insertions(+), 61 deletions(-) diff --git a/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift b/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift index b71618f3..38074451 100644 --- a/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift +++ b/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift @@ -123,14 +123,21 @@ 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 - } else { - leaveANoteButton - } + leaveANoteButton } Rectangle().fill(.white.opacity(0.07)).frame(height: 1) } + // A sheet, not a field in the rail — the only shape text entry has ever worked in + // this app, and now it is clear why. Ghostty's `NSView` holds the window's first + // responder while a loop is open, and `@FocusState` cannot take it off an AppKit + // view inside the same window: the caret never arrived, so every keystroke went to + // the terminal and the draft stayed empty. A sheet gets its own key window, which + // is why `JumpPaletteView` focuses with a bare `onAppear` and why the rename and + // delete prompts are hosted the same way. See `AppView`'s note on ⌘K opening over + // a terminal as readily as over a canvas. + .sheet(isPresented: $isComposing) { + composerSheet + } } private var unreadIDs: Set { @@ -327,67 +334,65 @@ struct ArtifactorySection: View { // MARK: - Composing - /// A human's voice on the board. Anchored at the foot, where the post will land. - private var composer: some View { - VStack(alignment: .leading, spacing: 7) { - TextField("topic (optional)", text: $draftTopic) - .textFieldStyle(.plain) - .font(.system(size: 10.5)) - .foregroundStyle(.white.opacity(0.75)) - TextField("a note for whoever comes next", text: $draft, axis: .vertical) - .textFieldStyle(.plain) - .font(.system(size: 12.5)) - .foregroundStyle(.white.opacity(0.92)) - .lineLimit(2...6) - .focused($draftFocused) + /// A human's voice on the board. + private var composerSheet: some View { + VStack(spacing: 12) { + Text("Leave a note").font(.headline) + + Text( + """ + Every loop in this project reads this board, including loops that do not exist \ + yet. Post what a peer or a successor should not have to rediscover — a dead end, \ + a decision, a claim you are staking. + """ + ) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + Form { + TextField("Topic (optional)", text: $draftTopic, prompt: Text("claims, build, findings")) + .autocorrectionDisabled() + TextField("Note", text: $draft, axis: .vertical) + .lineLimit(3...10) + .focused($draftFocused) + } + .formStyle(.columns) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 6) { Image(systemName: "person") - .font(.system(size: 8)) - .foregroundStyle(.white.opacity(0.42)) - Text("posting as a human") .font(.system(size: 10)) - .foregroundStyle(.white.opacity(0.42)) + .foregroundStyle(.secondary) + Text("Posts as “a human” — the app carries no loop identity.") + .font(.caption) + .foregroundStyle(.secondary) Spacer(minLength: 0) - // Shown only as the bound approaches: a counter on an empty field is chrome, - // and the daemon refuses anything over this anyway. + // Only as the bound approaches: the daemon refuses anything over it, and a + // counter on an empty field is chrome. if remainingBytes <= 120 { Text("\(remainingBytes)") - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(remainingBytes < 0 ? .red : .white.opacity(0.5)) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(remainingBytes < 0 ? .red : .secondary) } + } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack { Button("Cancel") { cancelCompose() } - .buttonStyle(.plain) - .font(.system(size: 10.5)) - .foregroundStyle(.white.opacity(0.5)) .keyboardShortcut(.cancelAction) + Spacer() Button("Post") { submit() } - .buttonStyle(.plain) - .font(.system(size: 10.5, weight: .semibold)) - .foregroundStyle( - canPost - ? AnyShapeStyle(Color(red: 0.549, green: 0.773, blue: 1.0)) - : AnyShapeStyle(.white.opacity(0.3)) - ) + .keyboardShortcut(.defaultAction) .disabled(!canPost) - .keyboardShortcut(.return, modifiers: .command) } } - .padding(.horizontal, 10) - .padding(.vertical, 9) - .background(.white.opacity(0.045), in: RoundedRectangle(cornerRadius: 9)) - .overlay { - 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 } - } + .padding(24) + .frame(width: 420) + // No run-loop hop needed, unlike the rail: the sheet's own window is key by the + // time this runs, which is the whole reason the composer moved into one. + .onAppear { draftFocused = true } } private var trimmedDraft: String { diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift index 8ed101b8..a4dec55c 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift @@ -195,14 +195,6 @@ struct LoopWorkspaceRail: View { node: node, isFolded: isBoardFolded, onToggleFold: onBoardFoldToggled, onExpand: onBoardExpanded) } - // Above `THIS LOOP` for the same reason the summary is: what other loops have - // said to you outranks where you sit in the graph, and a section you have to - // scroll to is a section that answers nothing at a glance. - if ArtifactoryPresentation.hasContent(graph: graph, enabled: artifactoryEnabled) { - ArtifactorySection( - node: node, graph: graph, isFolded: isArtifactoryFolded, - onToggleFold: onArtifactoryFoldToggled, onPost: onArtifactoryPost) - } section("THIS LOOP") { RailMinimap(node: node, upstream: inbound, downstream: outbound.map(\.target)) } @@ -219,6 +211,19 @@ struct LoopWorkspaceRail: View { section("RECENT PASSES") { RailSparkline(node: node) } } Spacer(minLength: 0) + // The board sits at the foot, under the spacer, so the rail's slack collects + // *above* it rather than inside it. It went in above `THIS LOOP` first, on the + // reasoning that what other loops said to you outranks where you sit in the + // graph — but the sections above it are fixed-height and the board is not, so a + // quiet rail left it stranded at the top with everything it says a long way from + // the button that answers it. Low and against the footer is also where a message + // board belongs: newest at the bottom, composer under it, the way every other + // thing you read messages in is arranged. + if ArtifactoryPresentation.hasContent(graph: graph, enabled: artifactoryEnabled) { + ArtifactorySection( + node: node, graph: graph, isFolded: isArtifactoryFolded, + onToggleFold: onArtifactoryFoldToggled, onPost: onArtifactoryPost) + } footer } .padding(12)