Fix ⌘F focus, hung-turn Stop, narrow tabs and other QA findings

This commit is contained in:
Ian Gulliver
2026-10-23 15:34:01 -07:00
parent ff8d4fb4be
commit 382d27d356
9 changed files with 78 additions and 65 deletions
+1
View File
@@ -36,6 +36,7 @@ Every build is signed for distribution outside the App Store: the team's Develop
- The transcript is a plain `VStack` in a `ScrollView`, not lazy: a `LazyVStack` estimates the height of rows it hasn't shown yet and corrects it when they scroll into view, without moving the scroll offset, so scrolling up through rows not yet shown jumped by each correction; SwiftUI can't move the offset mid-scroll without stopping its momentum.
- The terminal cursor is a steady block: SwiftTerm's blinking cursor restarts its Core Animation fade on every keypress, scheduled 0.7 s out, and on a ProMotion display macOS then holds the window's screen update until that fade starts, so each typed letter appears about 0.7 s late. SwiftTerm redraws the whole screen whenever a terminal color is set, even when its value hasn't changed.
- A plain `.background(color)` on a view inside a container with a shaped background (`.background(_, in: RoundedRectangle(...))`) fills a smaller rounded shape concentric with the container's, not its own rectangle, on macOS 27 and iOS 27. Stacked rows each drawn that way get notched ends, as the Changes view's diff lines did. Give a row's fill `in: Rectangle()` when it must reach its edges square.
- A full-size diagram is drawn at its zoom, in tiles, and a pinch is folded into that zoom when it ends. Scaling a natural-size SwiftUI view down with `NSScrollView` magnification or `UIScrollView` zooming draws only about a window's width of it, so fitting a wide diagram that way shows its first few boxes and blank space.
- The Page view loads a worktree page from a plain-HTTP server the app runs on a loopback port (`http://127.0.0.1:<port>/<task id>/<path>`), which fetches each file through the server's `getTaskRaw` endpoint. The page's relative links to its stylesheets, scripts and images resolve to files beside it; absolute and `file://` references don't load. "↗ Browser" opens the same address. iOS reclaims the listening socket while the app is suspended, so the app binds it again on the same port each time it returns to the foreground. Previews load from their loopback forwards at `<name>-<task>.localhost`. App Transport Security blocks plain HTTP to `*.localhost` even with `NSAllowsLocalNetworking`; the `localhost` exception domain with subdomains in `Info.plist` allows it (loopback IP addresses need no exception). `NSAllowsArbitraryLoadsInWebContent` lets the Page view load plain-HTTP websites without lifting ATS for the app's own requests.
+12 -2
View File
@@ -324,6 +324,11 @@ func (m *Manager) Interrupt(ctx context.Context, sessionID string) (api.AgentSes
defer cancel()
err = l.sess.interrupt(ictx)
if errors.Is(err, context.DeadlineExceeded) {
slog.Warn("agent didn't answer interrupt, ending its process", "session", sess.Id)
l.sess.kill()
return sess, nil
}
if err != nil {
return api.AgentSession{}, err
}
@@ -999,6 +1004,7 @@ func (m *Manager) pump(l *live) {
delete(m.live, l.id)
closing := m.closing
quiet := closing || l.stopping
interrupted := l.interrupted
m.mu.Unlock()
orphaned := len(l.subagents) > 0 && !closing
@@ -1040,14 +1046,18 @@ func (m *Manager) pump(l *live) {
}
}
m.endMergeTurn(l.id, true)
end, err := m.store.EndTurn(ctx, l.id, agentActor, map[string]any{"error": "agent process exited mid-turn", "stopped": true}, nil, false, false, false)
reason := "agent process exited mid-turn"
if interrupted {
reason = "interrupted; the agent didn't answer, so byre ended its process"
}
end, err := m.store.EndTurn(ctx, l.id, agentActor, map[string]any{"error": reason, "stopped": true}, nil, false, false, false)
if err != nil {
slog.Error("record session exit", "session", l.id, "err", err)
}
if end.Discard != store.DiscardNone {
return
}
if l.owner == "" {
if l.owner == "" && !interrupted {
m.recordError(l, "The agent process exited mid-turn. Send a message to resume it.")
}
}
+8 -4
View File
@@ -86,13 +86,17 @@ func (m *Manager) settleFacts(ctx context.Context, dirty map[string]bool) error
func (m *Manager) settleTaskFacts(ctx context.Context, t api.Task, p api.Project) error {
m.factsRun.Lock()
defer m.factsRun.Unlock()
if t.WorktreePath != "" {
gone := func() bool {
_, err := os.Stat(t.WorktreePath)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return t.WorktreePath != "" && errors.Is(err, fs.ErrNotExist)
}
if gone() {
return nil
}
g, err := m.gitFacts(ctx, t, p)
if err != nil && gone() {
return nil
}
if err != nil {
slog.Error("compute merge facts", "task", t.Id, "err", err)
return nil
+2 -2
View File
@@ -652,8 +652,8 @@ private struct DiffLineRow: View {
}
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(row.kind.background)
.background(hover ? Palette.accent.opacity(0.08) : .clear)
.background(row.kind.background, in: Rectangle())
.background(hover ? Palette.accent.opacity(0.08) : .clear, in: Rectangle())
.overlay(alignment: .leading) {
if mark {
Rectangle().fill(Palette.accent).frame(width: 3)
+4
View File
@@ -101,6 +101,10 @@ struct FilterField: View {
var body: some View {
TextField(placeholder, text: $text)
.modifier(FieldStyle(focused: focused))
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
.focused($focused)
.onChange(of: focusSignal, initial: true) { old, new in
if new != 0 && (old != new || !focused) {
+44 -53
View File
@@ -117,29 +117,9 @@ private struct LeftPane: View {
let tab = overlays.leftTab
VStack(spacing: 0) {
HStack(spacing: 4) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
PaneTabButton(label: "Lead", active: tab == .lead) {
pick(.lead)
}
PaneTabButton(label: "Status", active: tab == .status) {
pick(.status)
}
PaneTabButton(label: "Notes", active: tab == .notes) {
pick(.notes)
}
PaneTabButton(label: "Workflows", active: tab == .workflows) {
pick(.workflows)
}
PaneTabButton(label: "Server", active: tab == .server) {
pick(.server)
}
PaneTabButton(label: "Settings", active: tab == .settings) {
pick(.settings)
}
}
FoldingTabs(tabs: [(.lead, "Lead"), (.status, "Status"), (.notes, "Notes"), (.workflows, "Workflows"), (.server, "Server"), (.settings, "Settings")], active: tab) { t in
pick(t)
}
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
HStack(spacing: 10) {
if !store.connected {
Text("reconnecting…")
@@ -411,12 +391,6 @@ private struct PreviewLink: View {
struct TabStrip: View {
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@Environment(\.paneAccent) private var accent
@ViewState private var widths: [TaskTab: CGFloat] = [:]
@ViewState private var moreWidth: CGFloat = 0
@ViewState private var stripWidth: CGFloat = 0
@ViewState private var moreFrame: CGRect = .zero
private struct Info {
var order: [TaskTab]
@@ -448,19 +422,41 @@ struct TabStrip: View {
return Info(order: order, tab: tab)
}
private func kept(_ info: Info) -> [TaskTab] {
let gap: CGFloat = 4
let inner = stripWidth - 24
func total(_ ts: [TaskTab]) -> CGFloat {
ts.reduce(0) { $0 + (widths[$1] ?? 0) + gap }
var body: some View {
let info = info()
FoldingTabs(tabs: info.order.map { ($0, tabLabels[$0]!) }, active: info.tab) { t in
store.openTab(t)
}
let all = info.order
if stripWidth == 0 || total(all) <= inner {
.padding(.horizontal, 12)
.overlay(alignment: .bottom) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
}
struct FoldingTabs<ID: Hashable>: View {
let tabs: [(id: ID, label: String)]
let active: ID
let pick: (ID) -> Void
@Environment(Overlays.self) private var overlays
@Environment(\.paneAccent) private var accent
@ViewState private var widths: [ID: CGFloat] = [:]
@ViewState private var moreWidth: CGFloat = 0
@ViewState private var stripWidth: CGFloat = 0
@ViewState private var moreFrame: CGRect = .zero
private func kept() -> [ID] {
let gap: CGFloat = 4
func total(_ ids: [ID]) -> CGFloat {
ids.reduce(0) { $0 + (widths[$1] ?? 0) + gap }
}
let all = tabs.map(\.id)
if stripWidth == 0 || total(all) <= stripWidth {
return all
}
let budget = inner - moreWidth - gap
var kept: [TaskTab] = []
for t in [info.tab] + all.filter({ $0 != info.tab }) {
let budget = stripWidth - moreWidth - gap
var kept: [ID] = []
for t in [active] + all.filter({ $0 != active }) {
if !kept.isEmpty && total(kept + [t]) > budget {
break
}
@@ -470,20 +466,19 @@ struct TabStrip: View {
}
var body: some View {
let info = info()
let kept = kept(info)
let hidden = info.order.filter { !kept.contains($0) }
let kept = kept()
let hidden = tabs.filter { !kept.contains($0.id) }
HStack(spacing: 4) {
ForEach(info.order.filter { kept.contains($0) }, id: \.self) { t in
PaneTabButton(label: tabLabels[t]!, active: t == info.tab) {
store.openTab(t)
ForEach(tabs.filter { kept.contains($0.id) }, id: \.id) { t in
PaneTabButton(label: t.label, active: t.id == active) {
pick(t.id)
}
}
if !hidden.isEmpty {
MoreButton {
overlays.menu = OverlayMenu(anchor: moreFrame, accent: accent, entries: hidden.map { t in
MenuEntry(id: t.rawValue, label: tabLabels[t]!) {
store.openTab(t)
MenuEntry(id: t.label, label: t.label) {
pick(t.id)
}
})
}
@@ -495,17 +490,16 @@ struct TabStrip: View {
}
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.clipped()
.background {
HStack(spacing: 4) {
ForEach(info.order, id: \.self) { t in
PaneTabButton(label: tabLabels[t]!, active: t == info.tab) {}
ForEach(tabs, id: \.id) { t in
PaneTabButton(label: t.label, active: t.id == active) {}
.onGeometryChange(for: CGFloat.self) { g in
g.size.width
} action: { w in
widths[t] = w
widths[t.id] = w
}
}
MoreButton {}
@@ -523,9 +517,6 @@ struct TabStrip: View {
} action: { w in
stripWidth = w
}
.overlay(alignment: .bottom) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
}
+1 -1
View File
@@ -137,7 +137,7 @@ private struct ServerColorPicker: View {
var body: some View {
let origin = store.api.origin.absoluteString
let current = Servers.shared.record(origin)?.color
HStack(spacing: 4) {
FlowLayout(spacing: 4) {
ForEach(taskPalette.indices, id: \.self) { i in
Button {
Servers.shared.setColor(origin, i)
+5
View File
@@ -99,6 +99,11 @@ final class Servers {
connections[origin] = nil
Task {
await connection.stop()
let scope = draftOrigin(parseOrigin(origin)!) + "."
for record in fetchRecords(FetchDescriptor<DraftRecord>()) where record.scope.hasPrefix(scope) {
persistence.delete(record)
}
saveRecords()
}
}
}
+1 -3
View File
@@ -46,9 +46,7 @@ func runGo(_ key: String, _ store: Store, _ overlays: Overlays) {
}
case "f":
overlays.switcher?.close()
if store.paneFocus.inTaskPane {
overlays.filterTodo += 1
}
overlays.filterTodo += 1
case "/":
overlays.showAlert(ShortcutsHelp())
default: