macOS client overlays, menus and notifications
This commit is contained in:
@@ -15,7 +15,9 @@ final class ChatModel {
|
||||
private(set) var scrollRequest: (seq: Int, id: Int, anchor: UnitPoint)?
|
||||
private(set) var fetching = false
|
||||
private(set) var olderGeneration = 0
|
||||
private(set) var hasUnmerged = false
|
||||
@ObservationIgnored var stuck = true
|
||||
@ObservationIgnored private var unmergedTimer: Task<Void, Never>?
|
||||
@ObservationIgnored private var offs: [() -> Void] = []
|
||||
@ObservationIgnored private var focusStart = true
|
||||
@ObservationIgnored private var visibleSummaries: Set<Int64> = []
|
||||
@@ -102,6 +104,9 @@ final class ChatModel {
|
||||
guard let feed else {
|
||||
return
|
||||
}
|
||||
Task {
|
||||
await loadUnmerged()
|
||||
}
|
||||
appended(feed.events)
|
||||
offs.append(feed.appended.add { [weak self] events in
|
||||
self?.appended(events)
|
||||
@@ -125,15 +130,38 @@ final class ChatModel {
|
||||
NotificationCenter.default.removeObserver(activeObserver)
|
||||
}
|
||||
activeObserver = nil
|
||||
unmergedTimer?.cancel()
|
||||
unmergedTimer = nil
|
||||
builder.stop()
|
||||
store.drafts?.flush()
|
||||
}
|
||||
|
||||
private func appended(_ events: [ByreEvent]) {
|
||||
if events.contains(where: { $0.kind == "agent.tool_result" || $0.kind == "turn.ended" }) {
|
||||
unmergedTimer?.cancel()
|
||||
unmergedTimer = Task { [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(1500))
|
||||
if !Task.isCancelled {
|
||||
await self?.loadUnmerged()
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = builder.append(events)
|
||||
jump()
|
||||
}
|
||||
|
||||
private func loadUnmerged() async {
|
||||
guard let taskId else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
let u = try await store.api.client.getUnmerged(path: .init(taskId: taskId)).ok.body.json
|
||||
hasUnmerged = u.commits > 0 || u.files > 0
|
||||
} catch {
|
||||
log.error("load unmerged changes: \(errorText(error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func takeFocusStart() -> Bool {
|
||||
let take = focusStart && session == nil
|
||||
focusStart = false
|
||||
@@ -275,9 +303,13 @@ private struct ChatHead: View {
|
||||
}
|
||||
} else {
|
||||
let sess = store.currentSession(task.id)
|
||||
let busy = sess?.state == .running
|
||||
header {
|
||||
CompactButton(sessionId: sess?.id, state: sess?.state, usage: sess.flatMap { model.builder.context[$0.id] })
|
||||
SyncButton(task: task, busy: sess?.state == .running)
|
||||
SyncButton(task: task, busy: busy)
|
||||
Spacer(minLength: 12)
|
||||
MergeButton(task: task, busy: busy, unmerged: model.hasUnmerged)
|
||||
DiscardButton(task: task, busy: busy)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,8 +319,8 @@ private struct ChatHead: View {
|
||||
private func header<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
content()
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.overlay(alignment: .bottom) {
|
||||
@@ -326,6 +358,69 @@ private struct SyncButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct MergeButton: View {
|
||||
let task: ByreTask
|
||||
let busy: Bool
|
||||
let unmerged: Bool
|
||||
@Environment(Store.self) private var store
|
||||
@Environment(Overlays.self) private var overlays
|
||||
|
||||
var body: some View {
|
||||
let target = store.projects.first { $0.id == task.projectId }?.defaultBranch ?? "the default branch"
|
||||
let held = task.mergeQueuedAt != nil
|
||||
let intoParent = task.mergingIntoParent
|
||||
let help = intoParent
|
||||
? "Waiting for the parent task's agent to merge this subtask"
|
||||
: held
|
||||
? "Waiting for the merge in progress in this project to finish"
|
||||
: task.mergeAfterReview
|
||||
? "Merges when the running review finishes with no findings; open to merge now instead (⌘S)"
|
||||
: task.parentTaskId != nil
|
||||
? "Merge this subtask into its parent task's branch, or into \(target) (⌘S)"
|
||||
: "The agent merges \(target) in, then byre squashes into one commit on \(target) and pushes it to origin; held while another task in the project is merging (⌘S)"
|
||||
Button(intoParent ? "Merging into parent" : held ? "Merge held" : task.mergeAfterReview ? "Merge after review" : "Merge") {
|
||||
Task {
|
||||
await confirmMerge(task.id, store, overlays)
|
||||
}
|
||||
}
|
||||
.buttonStyle(WebButtonStyle())
|
||||
.disabled(busy || held || intoParent || !unmerged)
|
||||
.help(help)
|
||||
.onGeometryChange(for: CGRect.self) { g in
|
||||
g.frame(in: .named("window"))
|
||||
} action: { frame in
|
||||
overlays.mergeFrame = frame
|
||||
}
|
||||
.onDisappear {
|
||||
overlays.mergeFrame = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DiscardButton: View {
|
||||
let task: ByreTask
|
||||
let busy: Bool
|
||||
@Environment(Store.self) private var store
|
||||
@Environment(Overlays.self) private var overlays
|
||||
@ViewState private var frame: CGRect = .zero
|
||||
|
||||
var body: some View {
|
||||
Button("Discard") {
|
||||
Task {
|
||||
await confirmDiscard(task, store, overlays, anchor: frame)
|
||||
}
|
||||
}
|
||||
.buttonStyle(WebButtonStyle(danger: true))
|
||||
.disabled(busy)
|
||||
.help("The agent writes the task summary, then byre deletes the worktree and branch without merging")
|
||||
.onGeometryChange(for: CGRect.self) { g in
|
||||
g.frame(in: .named("window"))
|
||||
} action: { f in
|
||||
frame = f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CompactButton: View {
|
||||
let sessionId: String?
|
||||
let state: AgentSession.StatePayload?
|
||||
|
||||
Reference in New Issue
Block a user