Fix overflow, diagram zoom, task-switch stall and other QA findings

This commit is contained in:
Ian Gulliver
2026-10-23 13:04:41 -07:00
parent 3da1661ca3
commit c337799147
17 changed files with 603 additions and 455 deletions
+1 -1
View File
@@ -125,7 +125,7 @@ final class API: Sendable {
"Byre-Client": clientID,
"Byre-Commit": Bundle.main.object(forInfoDictionaryKey: "ByreCommit") as! String,
"Byre-Device": deviceKind,
"Byre-Host": deviceName,
"Byre-Host": deviceName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)!,
]
config.proxyConfigurations = [tailnetNode.proxy()]
session = URLSession(configuration: config, delegate: RequestTiming(), delegateQueue: nil)
+136 -37
View File
@@ -1346,11 +1346,14 @@ func fill(_ b: PlacedBox) -> Color {
struct BoxCanvas: View {
let layout: BoxLayout
var focus: BoxFocus?
var region: CGRect?
var body: some View {
Canvas { context, size in
let scale = layout.size.width > 0 ? size.width / layout.size.width : 1
let r = region ?? CGRect(origin: .zero, size: layout.size)
let scale = r.width > 0 ? size.width / r.width : 1
context.scaleBy(x: scale, y: scale)
context.translateBy(x: -r.minX, y: -r.minY)
draw(&context)
}
}
@@ -1565,17 +1568,43 @@ struct DiagramViewer: View {
}
}
private let minZoom: CGFloat = 0.25
private let maxZoom: CGFloat = 6
private struct SizedCanvas: View {
let layout: BoxLayout
let focus: BoxFocus?
let zoom: CGFloat
var body: some View {
BoxCanvas(layout: layout, focus: focus)
.frame(width: layout.size.width, height: layout.size.height)
.diagramMenu(layout)
ZStack(alignment: .topLeading) {
ForEach(Array(tiles(layout.size, side: 1024 / zoom).enumerated()), id: \.offset) { _, r in
BoxCanvas(layout: layout, focus: focus, region: r)
.frame(width: r.width * zoom, height: r.height * zoom)
.offset(x: r.minX * zoom, y: r.minY * zoom)
}
}
.frame(width: layout.size.width * zoom, height: layout.size.height * zoom, alignment: .topLeading)
.diagramMenu(layout)
}
}
// The zoom views draw at their zoom rather than scaling a natural-size view, since SwiftUI then draws only a window's
// width of it; tiles keep each canvas within the largest texture the GPU takes.
private func tiles(_ size: CGSize, side: CGFloat) -> [CGRect] {
var out: [CGRect] = []
var y: CGFloat = 0
while y < size.height {
var x: CGFloat = 0
while x < size.width {
out.append(CGRect(x: x, y: y, width: min(side, size.width - x), height: min(side, size.height - y)))
x += side
}
y += side
}
return out
}
private func click(_ layout: BoxLayout, _ p: CGPoint, tap: (String?) -> Void, open: (String) -> Void) {
if let id = layout.opener(at: p) {
open(id)
@@ -1614,15 +1643,64 @@ private final class CenteringClipView: NSClipView {
}
private final class FittingScrollView: NSScrollView {
var fitted = false
let host: NSHostingView<SizedCanvas>
var diagram: BoxLayout
var focus: BoxFocus?
private(set) var zoom: CGFloat = 1
private var fit: CGFloat = 1
private var fitted: CGSize?
init(_ diagram: BoxLayout, focus: BoxFocus?) {
self.diagram = diagram
self.focus = focus
host = NSHostingView(rootView: SizedCanvas(layout: diagram, focus: focus, zoom: 1))
super.init(frame: .zero)
contentView = CenteringClipView()
drawsBackground = false
hasVerticalScroller = true
hasHorizontalScroller = true
autohidesScrollers = true
allowsMagnification = true
documentView = host
NotificationCenter.default.addObserver(self, selector: #selector(endMagnify), name: NSScrollView.didEndLiveMagnifyNotification, object: self)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
func render() {
host.rootView = SizedCanvas(layout: diagram, focus: focus, zoom: zoom)
host.frame = CGRect(x: 0, y: 0, width: diagram.size.width * zoom, height: diagram.size.height * zoom)
minMagnification = min(minZoom, fit) / zoom
maxMagnification = maxZoom / zoom
}
override func layout() {
super.layout()
guard !fitted, let doc = documentView, bounds.width > 0, bounds.height > 0, doc.frame.width > 0 else {
guard fitted != bounds.size, bounds.width > 0, bounds.height > 0, diagram.size.width > 0, diagram.size.height > 0 else {
return
}
fitted = true
magnification = min(2, bounds.width / doc.frame.width, bounds.height / doc.frame.height)
fitted = bounds.size
fit = min(2, bounds.width / diagram.size.width, bounds.height / diagram.size.height)
zoom = fit
magnification = 1
render()
}
@objc private func endMagnify() {
let m = magnification
guard m != 1 else {
return
}
let visible = contentView.bounds
let center = CGPoint(x: visible.midX * m, y: visible.midY * m)
zoom *= m
magnification = 1
render()
let size = contentView.bounds.size
contentView.scroll(to: CGPoint(x: center.x - size.width / 2, y: center.y - size.height / 2))
reflectScrolledClipView(contentView)
}
}
@@ -1635,16 +1713,18 @@ private struct ZoomingCanvas: NSViewRepresentable {
@MainActor
final class Coordinator: NSObject {
var canvas: ZoomingCanvas
weak var scroll: FittingScrollView?
init(_ canvas: ZoomingCanvas) {
self.canvas = canvas
}
@objc func clicked(_ g: NSClickGestureRecognizer) {
guard let view = g.view else {
guard let view = g.view, let zoom = scroll?.zoom else {
return
}
click(canvas.layout, g.location(in: view), tap: canvas.tap, open: canvas.open)
let p = g.location(in: view)
click(canvas.layout, CGPoint(x: p.x / zoom, y: p.y / zoom), tap: canvas.tap, open: canvas.open)
}
}
@@ -1653,68 +1733,74 @@ private struct ZoomingCanvas: NSViewRepresentable {
}
func makeNSView(context: Context) -> FittingScrollView {
let scroll = FittingScrollView()
scroll.contentView = CenteringClipView()
scroll.drawsBackground = false
scroll.hasVerticalScroller = true
scroll.hasHorizontalScroller = true
scroll.autohidesScrollers = true
scroll.allowsMagnification = true
scroll.minMagnification = 0.25
scroll.maxMagnification = 6
let host = NSHostingView(rootView: SizedCanvas(layout: layout, focus: focus))
host.frame = CGRect(origin: .zero, size: layout.size)
host.addGestureRecognizer(NSClickGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.clicked)))
scroll.documentView = host
let scroll = FittingScrollView(layout, focus: focus)
scroll.host.addGestureRecognizer(NSClickGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.clicked)))
context.coordinator.scroll = scroll
return scroll
}
func updateNSView(_ scroll: FittingScrollView, context: Context) {
context.coordinator.canvas = self
(scroll.documentView as? NSHostingView<SizedCanvas>)?.rootView = SizedCanvas(layout: layout, focus: focus)
scroll.diagram = layout
scroll.focus = focus
scroll.render()
}
}
#else
private struct TappableCanvas: View {
let layout: BoxLayout
let focus: BoxFocus?
let zoom: CGFloat
let tap: (String?) -> Void
let open: (String) -> Void
var body: some View {
SizedCanvas(layout: layout, focus: focus)
SizedCanvas(layout: layout, focus: focus, zoom: zoom)
.contentShape(Rectangle())
.onTapGesture(coordinateSpace: .local) { p in
click(layout, p, tap: tap, open: open)
click(layout, CGPoint(x: p.x / zoom, y: p.y / zoom), tap: tap, open: open)
}
}
}
private final class FittingScrollView: UIScrollView, UIScrollViewDelegate {
var canvas: TappableCanvas
let host: UIHostingController<TappableCanvas>
private var fitted = false
private var zoom: CGFloat = 1
private var fit: CGFloat = 1
private var fitted: CGSize?
init(_ canvas: TappableCanvas, size: CGSize) {
init(_ canvas: TappableCanvas) {
self.canvas = canvas
host = UIHostingController(rootView: canvas)
super.init(frame: .zero)
host.view.backgroundColor = .clear
host.view.frame = CGRect(origin: .zero, size: size)
addSubview(host.view)
contentSize = size
delegate = self
minimumZoomScale = 0.25
maximumZoomScale = 6
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
func render() {
let size = canvas.layout.size
host.rootView = TappableCanvas(layout: canvas.layout, focus: canvas.focus, zoom: zoom, tap: canvas.tap, open: canvas.open)
host.view.frame = CGRect(x: 0, y: 0, width: size.width * zoom, height: size.height * zoom)
contentSize = host.view.frame.size
minimumZoomScale = min(minZoom, fit) / zoom
maximumZoomScale = maxZoom / zoom
}
override func layoutSubviews() {
super.layoutSubviews()
if !fitted, bounds.width > 0, bounds.height > 0, host.view.bounds.width > 0 {
fitted = true
zoomScale = min(2, bounds.width / host.view.bounds.width, bounds.height / host.view.bounds.height)
let size = canvas.layout.size
if fitted != bounds.size, bounds.width > 0, bounds.height > 0, size.width > 0, size.height > 0 {
fitted = bounds.size
fit = min(2, bounds.width / size.width, bounds.height / size.height)
zoom = fit
zoomScale = 1
render()
}
center()
}
@@ -1727,6 +1813,18 @@ private final class FittingScrollView: UIScrollView, UIScrollViewDelegate {
center()
}
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
guard scale != 1 else {
return
}
let offset = contentOffset
zoom *= scale
zoomScale = 1
render()
contentOffset = offset
center()
}
private func center() {
let x = max(0, (bounds.width - contentSize.width) / 2)
let y = max(0, (bounds.height - contentSize.height) / 2)
@@ -1741,11 +1839,12 @@ private struct ZoomingCanvas: UIViewRepresentable {
let open: (String) -> Void
func makeUIView(context: Context) -> FittingScrollView {
FittingScrollView(TappableCanvas(layout: layout, focus: focus, tap: tap, open: open), size: layout.size)
FittingScrollView(TappableCanvas(layout: layout, focus: focus, zoom: 1, tap: tap, open: open))
}
func updateUIView(_ scroll: FittingScrollView, context: Context) {
scroll.host.rootView = TappableCanvas(layout: layout, focus: focus, tap: tap, open: open)
scroll.canvas = TappableCanvas(layout: layout, focus: focus, zoom: 1, tap: tap, open: open)
scroll.render()
}
}
#endif
+6 -4
View File
@@ -457,7 +457,7 @@ final class ChatModel {
if let q = groups.first(where: { $0.id == "questions" }) {
return flagged(q)
}
if let busy = store.progress(task) {
if let busy = task.state == .running ? store.taskState(task) : store.progress(task) {
return ChatStatus(label: busy.label, help: "\(busy.title)\(end)", tone: .working, group: nil)
}
return groups.first.map(flagged)
@@ -991,7 +991,7 @@ private struct TranscriptScroll: View {
}
.scrollTargetLayout()
.overlay(alignment: .bottomLeading) {
if running && model.key != nil {
if running && !model.paused && model.key != nil {
Scanner(color: accent)
.frame(width: 84, height: 2)
.offset(y: 8)
@@ -1303,8 +1303,10 @@ struct WhenText: View {
let when: Date
var body: some View {
Text(ago(when))
.help(isoText(when))
TimelineView(.periodic(from: when, by: 60)) { _ in
Text(ago(when))
}
.help(isoText(when))
}
}
+25 -6
View File
@@ -659,12 +659,31 @@ struct ComposerView: View {
chat.showStatus(s)
}
}
HStack(spacing: 8) {
controls(m)
.frame(maxWidth: .infinity, alignment: .leading)
status
actions(live?.id, running: sessionState == .running, paused: live?.paused ?? false)
.frame(maxWidth: .infinity, alignment: .trailing)
let buttons = actions(live?.id, running: sessionState == .running, paused: live?.paused ?? false)
ViewThatFits(in: .horizontal) {
HStack(spacing: 8) {
controls(m)
.frame(maxWidth: .infinity, alignment: .leading)
status
buttons
.frame(maxWidth: .infinity, alignment: .trailing)
}
VStack(alignment: .trailing, spacing: 8) {
status
HStack(spacing: 8) {
controls(m)
.frame(maxWidth: .infinity, alignment: .leading)
buttons
}
}
VStack(alignment: .leading, spacing: 8) {
controls(m)
HStack(spacing: 8) {
Spacer(minLength: 0)
status
buttons
}
}
}
}
.padding(.horizontal, 16)
-13
View File
@@ -137,9 +137,6 @@ final class Overlays {
if switcher != nil {
return
}
#if os(macOS)
logFocus("open switcher")
#endif
switcher = SwitcherModel(store: store, overlays: self, anchor: pickerFrame)
}
@@ -293,16 +290,6 @@ final class Overlays {
#endif
}
#if os(macOS)
@MainActor
func logFocus(_ what: String) {
let key = NSApp.keyWindow
let main = NSApp.mainWindow
let responder = key?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
log.notice("focus debug: \(what, privacy: .public) active=\(NSApp.isActive) key=\(key?.title ?? "nil", privacy: .public) main=\(main?.title ?? "nil", privacy: .public) mainIsKey=\(main?.isKeyWindow ?? false) responder=\(responder, privacy: .public)")
}
#endif
struct ModalText: View {
let text: String
+8 -4
View File
@@ -11,11 +11,11 @@ final class NetworkWatch {
static let shared = NetworkWatch()
private let monitor = NWPathMonitor()
private var seen = false
private var last: String?
private init() {
monitor.pathUpdateHandler = { [weak self] path in
let summary = "\(path.status) \(path.availableInterfaces.map(\.name).joined(separator: " "))"
let summary = "\(path.status) \(path.availableInterfaces.map(\.name).joined(separator: " ")) via \(path.gateways.map { "\($0)" }.joined(separator: " "))"
Task { @MainActor in
self?.pathChanged(summary)
}
@@ -40,8 +40,12 @@ final class NetworkWatch {
}
private func pathChanged(_ summary: String) {
guard seen else {
seen = true
let first = last == nil
guard summary != last else {
return
}
last = summary
if first {
return
}
log.notice("network changed (\(summary, privacy: .public)), reconnecting")
-1
View File
@@ -184,7 +184,6 @@ final class PaneFocus {
}
private func changed(_ name: Notification.Name) {
logFocus(name.rawValue)
update()
}
+2 -1
View File
@@ -66,6 +66,7 @@ private struct SheetPanel: View {
let sheet: Sheet
let overLeft: Bool
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
var body: some View {
let task = store.selectedTaskId.flatMap { store.findTask($0) }
@@ -129,7 +130,7 @@ private struct SheetPanel: View {
case .diagram: "Diagram"
}
if !overLeft {
return "Lead · \(label)"
return "\(overlays.leftTab.rawValue.capitalized) · \(label)"
}
let project = store.focusedProject()?.name ?? ""
guard let task else {
+6 -1
View File
@@ -56,6 +56,8 @@ private let stateTitles: [ByreTask.StatePayload: TaskState] = [
.discarded: TaskState(kind: .idle, label: "Discarded", title: "The task was discarded"),
]
private let pausedState = TaskState(kind: .held, label: "Paused", title: "The agent holds after its current step until you resume it")
@MainActor
@Observable
final class Store {
@@ -267,7 +269,10 @@ final class Store {
}
func taskState(_ task: ByreTask) -> TaskState {
stateTitles[task.state]!
if task.state == .running, (sessions[task.id] ?? []).contains(where: { $0.paused }) {
return pausedState
}
return stateTitles[task.state]!
}
func progress(_ task: ByreTask) -> TaskState? {
-4
View File
@@ -457,15 +457,11 @@ private struct SwitcherBox: View {
.onAppear {
DispatchQueue.main.async {
#if os(macOS)
logFocus("switcher before focus")
if model.inList {
overlays.window?.makeFirstResponder(nil)
} else {
focused = true
}
DispatchQueue.main.async {
logFocus("switcher after focus focused=\(focused)")
}
#else
focused = !model.inList
#endif
+5
View File
@@ -1114,6 +1114,11 @@ private func resultText(_ content: (any Sendable)?) -> String {
if let d = c as? JSONDict, let text = d["text"] {
return jsString(text)
}
if let d = c as? JSONDict, str(d, "type") == "image" {
let source = d["source"] as? JSONDict
let size = (source?["data"] as? String).map { " " + bytes(Int64($0.utf8.count / 4 * 3)) } ?? ""
return "[image\(source.flatMap { str($0, "media_type") }.map { " " + $0 } ?? "")\(size)]"
}
return compactJSON(c)
}.joined(separator: "\n")
}
+26 -7
View File
@@ -337,7 +337,10 @@ private struct StepRow: View {
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(guide).foregroundStyle(Palette.muted)
Text(guide)
.font(Fonts.font(13, mono: true))
.foregroundStyle(Palette.muted)
.fixedSize()
if node.parent?.kind == .random {
WeightField(node: node)
}
@@ -352,13 +355,14 @@ private struct StepRow: View {
.frame(width: 12)
}
.buttonStyle(.plain)
TextField("Stage name", text: $node.name)
.textFieldStyle(.plain)
.font(Fonts.font(13, weight: 700))
if let agents = node.agents {
Text("team of \(agents.count)").foregroundStyle(Palette.muted).fixedSize()
if touchOnly {
VStack(alignment: .leading, spacing: 4) {
stageName
stageModel
}
} else {
ModelPicker(model: $node.model, effort: $node.effort)
stageName
stageModel
}
case .order:
Text("in order").foregroundStyle(Palette.muted)
@@ -406,6 +410,21 @@ private struct StepRow: View {
}
}
private var stageName: some View {
TextField("Stage name", text: $node.name)
.textFieldStyle(.plain)
.font(Fonts.font(13, weight: 700))
}
@ViewBuilder
private var stageModel: some View {
if let agents = node.agents {
Text("team of \(agents.count)").foregroundStyle(Palette.muted).fixedSize()
} else {
ModelPicker(model: $node.model, effort: $node.effort)
}
}
private var actions: some View {
Menu {
switch node.kind {