Fix the app's pane focus reporting and hold show on a right-pane draft

This commit is contained in:
Ian Gulliver
2026-10-18 09:36:03 -07:00
parent 5d547861eb
commit e2ef592dfd
3 changed files with 27 additions and 27 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -462,7 +462,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
"Omit both task_id and project_id to clear the pane. "+ "Omit both task_id and project_id to clear the pane. "+
"If the pane already shows that task (or project) on that tab, only the tab order changes and you are told so, even while the human is working in the pane. "+ "If the pane already shows that task (or project) on that tab, only the tab order changes and you are told so, even while the human is working in the pane. "+
"Otherwise refused for 10 seconds after the human last moved the pane themselves (switching task or tab), whatever else holds; if a show was refused for it, byre signals you \"The move hold is over\" when those 10 seconds pass without another move, and sends no signal otherwise. "+ "Otherwise refused for 10 seconds after the human last moved the pane themselves (switching task or tab), whatever else holds; if a show was refused for it, byre signals you \"The move hold is over\" when those 10 seconds pass without another move, and sends no signal otherwise. "+
"Otherwise refused while the human is working in the right pane, unless it is on the project tab, or what it shows is finished (a merged or discarded task, or an inbox tab with nothing left), "+ "Otherwise refused while the human is working in the right pane (focused in it, or with unsent text in it), unless it is on the project tab, or what it shows is finished (a merged or discarded task, or an inbox tab with nothing left), "+
"or it is on the transcript tab of a task whose agent is mid-turn, the human has no unsent text there, and either the human sent the message that started that turn since the pane last moved or the pane moved there 10 seconds ago or more; byre signals you \"Your right pane is free\" when either happens. "+ "or it is on the transcript tab of a task whose agent is mid-turn, the human has no unsent text there, and either the human sent the message that started that turn since the pane last moved or the pane moved there 10 seconds ago or more; byre signals you \"Your right pane is free\" when either happens. "+
"Also refused while the pane is on the project tab and the human started a TODO item there in the last 10 seconds, unless they switched task or tab or messaged you since; if a show was refused for it, byre signals you \"The start hold is over\" when those 10 seconds pass with the pane still on the project tab, and sends no signal otherwise.", "Also refused while the pane is on the project tab and the human started a TODO item there in the last 10 seconds, unless they switched task or tab or messaged you since; if a show was refused for it, byre signals you \"The start hold is over\" when those 10 seconds pass with the pane still on the project tab, and sends no signal otherwise.",
`{"type": "object", "properties": { `{"type": "object", "properties": {
@@ -492,7 +492,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
return "Already shown on that tab; the tab order was updated.", nil return "Already shown on that tab; the tab order was updated.", nil
} }
m.mu.Lock() m.mu.Lock()
focused := m.paneFocus[user.Login].Focused working := m.paneFocus[user.Login].Focused || m.paneFocus[user.Login].Drafting
humanMoved := m.humanMoved[user.Login] humanMoved := m.humanMoved[user.Login]
since := time.Since(humanMoved) since := time.Since(humanMoved)
moveHeld := since < moveHold moveHeld := since < moveHold
@@ -510,7 +510,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
if startHeld { if startHeld {
return "", fmt.Errorf("%w: the human just started a TODO item from the project tab and is likely to start more; tell them in chat instead. The hold ends %s after that start, unless they move the pane or message you first; byre signals you \"The start hold is over\" when it ends, because this show was refused", ErrConflict, startHold) return "", fmt.Errorf("%w: the human just started a TODO item from the project tab and is likely to start more; tell them in chat instead. The hold ends %s after that start, unless they move the pane or message you first; byre signals you \"The start hold is over\" when it ends, because this show was refused", ErrConflict, startHold)
} }
if focused && current.Tab != api.TabProject { if working && current.Tab != api.TabProject {
finished, err := m.paneFinished(ctx, current) finished, err := m.paneFinished(ctx, current)
if err != nil { if err != nil {
return "", err return "", err
+23 -23
View File
@@ -24,7 +24,6 @@ final class PaneFocus {
if self.window === window { if self.window === window {
return return
} }
log.notice("pane focus: attach window=\(ObjectIdentifier(window).debugDescription, privacy: .public) title=\(window.title, privacy: .public)")
detach() detach()
self.window = window self.window = window
let center = NotificationCenter.default let center = NotificationCenter.default
@@ -61,7 +60,6 @@ final class PaneFocus {
} }
func detach() { func detach() {
log.notice("pane focus: detach window=\(self.window.map { ObjectIdentifier($0).debugDescription } ?? "nil", privacy: .public) observers=\(self.observers.count)")
for o in observers { for o in observers {
NotificationCenter.default.removeObserver(o) NotificationCenter.default.removeObserver(o)
} }
@@ -121,13 +119,12 @@ final class PaneFocus {
} }
private func responderChanged() { private func responderChanged() {
guard let view = window?.firstResponder as? NSView, view !== window?.contentView else { guard let view = window?.firstResponder as? NSView, view !== window?.contentView, !view.visibleRect.isEmpty else {
update() update()
return return
} }
if let p = contentPoint(view.bounds, in: view) { if let p = contentPoint(view.visibleRect, in: view) {
inside = paneFrame.contains(p) inside = paneFrame.contains(p)
log.notice("pane focus: responder \(String(describing: type(of: view)), privacy: .public) point=\(p.debugDescription, privacy: .public) frame=\(self.paneFrame.debugDescription, privacy: .public) inside=\(self.inside)")
} }
update() update()
} }
@@ -138,7 +135,6 @@ final class PaneFocus {
} }
let p = content.convert(location, from: nil) let p = content.convert(location, from: nil)
inside = paneFrame.contains(CGPoint(x: p.x, y: content.isFlipped ? p.y : content.bounds.height - p.y)) inside = paneFrame.contains(CGPoint(x: p.x, y: content.isFlipped ? p.y : content.bounds.height - p.y))
log.notice("pane focus: click point=\(p.debugDescription, privacy: .public) flipped=\(content.isFlipped) frame=\(self.paneFrame.debugDescription, privacy: .public) inside=\(self.inside)")
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.responderChanged() self?.responderChanged()
} }
@@ -147,9 +143,6 @@ final class PaneFocus {
private func update() { private func update() {
let now = (window?.isKeyWindow ?? false) && NSApp.isActive && inside let now = (window?.isKeyWindow ?? false) && NSApp.isActive && inside
let draft = !drafts.isEmpty let draft = !drafts.isEmpty
let attached = window.map { ObjectIdentifier($0).debugDescription } ?? "nil"
let keyWindow = NSApp.keyWindow.map { ObjectIdentifier($0).debugDescription } ?? "nil"
log.notice("pane focus: update window=\(attached, privacy: .public) keyWindow=\(keyWindow, privacy: .public) key=\(self.window?.isKeyWindow ?? false) active=\(NSApp.isActive) inside=\(self.inside) now=\(now) draft=\(draft) focused=\(self.focused) drafting=\(self.drafting)")
if now == focused && draft == drafting { if now == focused && draft == drafting {
return return
} }
@@ -165,7 +158,6 @@ final class PaneFocus {
await previous?.value await previous?.value
do { do {
_ = try await api.client.setPaneFocus(body: .json(body)).noContent _ = try await api.client.setPaneFocus(body: .json(body)).noContent
log.notice("pane focus: sent focused=\(body.focused) drafting=\(body.drafting)")
} catch { } catch {
log.error("set pane focus: \(errorText(error), privacy: .public)") log.error("set pane focus: \(errorText(error), privacy: .public)")
} }
@@ -176,21 +168,29 @@ final class PaneFocus {
struct WindowReader: NSViewRepresentable { struct WindowReader: NSViewRepresentable {
let found: (NSWindow) -> Void let found: (NSWindow) -> Void
func makeNSView(context: Context) -> NSView { func makeNSView(context: Context) -> WindowReaderView {
let view = NSView() WindowReaderView(found: found)
DispatchQueue.main.async {
if let w = unsafe view.window {
found(w)
}
}
return view
} }
func updateNSView(_ nsView: NSView, context: Context) { func updateNSView(_ nsView: WindowReaderView, context: Context) {}
DispatchQueue.main.async { }
if let w = unsafe nsView.window {
found(w) final class WindowReaderView: NSView {
} private let found: (NSWindow) -> Void
init(found: @escaping (NSWindow) -> Void) {
self.found = found
super.init(frame: .zero)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if let w = unsafe window {
found(w)
} }
} }
} }