From 8b89f14694389d7722ec0153a0c7fa574e52235e Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Fri, 23 Oct 2026 14:56:14 -0700 Subject: [PATCH] Skip rebuilding the composer bar on unrelated transcript updates --- mac/Sources/Byre/Chat.swift | 4 +- mac/Sources/Byre/Composer.swift | 279 +++++++++++++++++--------------- 2 files changed, 155 insertions(+), 128 deletions(-) diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index c91a97a..02490e7 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -28,14 +28,14 @@ Start in [Settings](byre-app://tab?name=settings): \(leadSettings) private let leadSetup = "Walk me through setting up byre: the tools its agents need, signing in to their vendors, and the macOS permissions on the server's Mac and on my Macs." -struct AttentionGroup: Identifiable { +struct AttentionGroup: Identifiable, Equatable { let id: String let label: String let help: String let rows: [Int] } -struct ChatStatus { +struct ChatStatus: Equatable { enum Tone { case attention, working, merged, discarded } diff --git a/mac/Sources/Byre/Composer.swift b/mac/Sources/Byre/Composer.swift index ce74b9d..c847a15 100644 --- a/mac/Sources/Byre/Composer.swift +++ b/mac/Sources/Byre/Composer.swift @@ -562,6 +562,144 @@ struct AgentEditor: View { } } +private struct ComposerBar: View, Equatable { + let chat: ChatModel + let status: ChatStatus? + let shown: Bool + let draftKey: String + let modelKey: String + let options: [String] + let selected: String + let selectedEffort: String + let sessionId: String? + let running: Bool + let paused: Bool + let closed: Bool + let toggle: () -> Void + let choose: @MainActor @Sendable (String) -> Void + let chooseEffort: @MainActor @Sendable (String) -> Void + let submit: () -> Void + @Environment(Overlays.self) private var overlays + + // Leaves out the closures: they read their state when they run. + nonisolated static func == (a: ComposerBar, b: ComposerBar) -> Bool { + a.chat === b.chat && a.status == b.status && a.shown == b.shown && a.draftKey == b.draftKey && a.modelKey == b.modelKey && a.options == b.options + && a.selected == b.selected && a.selectedEffort == b.selectedEffort && a.sessionId == b.sessionId && a.running == b.running && a.paused == b.paused + && a.closed == b.closed + } + + var body: some View { + let status = status.map { s in + StatusButton(status: s) { + chat.showStatus(s) + } + } + let buttons = actions + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { + controls() + .frame(maxWidth: .infinity, alignment: .leading) + status + buttons + .frame(maxWidth: .infinity, alignment: .trailing) + } + VStack(alignment: .trailing, spacing: 8) { + status + HStack(spacing: 8) { + controls() + .frame(maxWidth: .infinity, alignment: .leading) + buttons + } + } + VStack(alignment: .leading, spacing: 8) { + controls() + HStack(spacing: 8) { + Spacer(minLength: 0) + status + buttons + } + } + VStack(alignment: .leading, spacing: 8) { + controls(stacked: true) + HStack(spacing: 8) { + Spacer(minLength: 0) + status + buttons + } + } + } + } + + private func controls(stacked: Bool = false) -> some View { + (stacked ? AnyLayout(VStackLayout(alignment: .leading, spacing: 8)) : AnyLayout(HStackLayout(spacing: 8))) { + Button(action: toggle) { + Image(systemName: shown ? "chevron.down" : "chevron.up") + .frame(height: 14) + } + .buttonStyle(WebButtonStyle()) + .help(shown ? "Hide the message box" : "Show the message box") + #if os(iOS) + ComposerMenu(key: draftKey, photos: shown, models: chat.team ? [] : options, selected: selected, effort: selectedEffort, choose: choose, chooseEffort: chooseEffort) + #else + if !chat.team { + Picker("", selection: Binding(get: { selected }, set: choose)) { + ForEach(options, id: \.self) { option in + Text(option).tag(option) + } + } + .labelsHidden() + .pickerStyle(.menu) + .fixedSize() + .help("Model for the next turn") + Picker("", selection: Binding(get: { selectedEffort }, set: chooseEffort)) { + ForEach(efforts(selected), id: \.self) { option in + Text(effortLabel(option)).tag(option) + } + } + .labelsHidden() + .pickerStyle(.menu) + .fixedSize() + .help("Effort for the next turn") + } + #endif + } + } + + private var actions: some View { + HStack(spacing: 8) { + Button { + if let sessionId { + chat.interrupt(sessionId, overlays) + } + } label: { + Image(systemName: "stop.fill") + .frame(width: 14, height: 14) + } + .buttonStyle(WebButtonStyle(danger: true)) + .disabled(!running) + .help("Interrupt the agent's turn") + if case .task = chat.kind, !chat.team, !closed { + Button { + if let sessionId { + chat.setPaused(sessionId, !paused, overlays) + } + } label: { + Image(systemName: paused ? "play.fill" : "pause.fill") + .frame(width: 14, height: 14) + } + .buttonStyle(WebButtonStyle()) + .disabled(sessionId == nil) + .help(paused ? "Resume the agent" : "Pause the agent after its current step") + } + Button(closed ? "Continue" : "Send", action: submit) + .buttonStyle(WebButtonStyle()) + .disabled(!shown) + .fixedSize() + .help(closed ? "Start a new task from the default branch whose agent continues this conversation with your message" : "Send the message") + } + } +} + struct ComposerView: View { let chat: ChatModel let key: String @@ -654,45 +792,23 @@ struct ComposerView: View { Thumbnails(key: draftKey) editor } - let status = chat.status().map { s in - StatusButton(status: s) { - chat.showStatus(s) - } - } - 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 - } - } - VStack(alignment: .leading, spacing: 8) { - controls(m, stacked: true) - HStack(spacing: 8) { - Spacer(minLength: 0) - status - buttons - } + ComposerBar(chat: chat, status: chat.status(), shown: shown, draftKey: draftKey, modelKey: m.modelKey, options: m.options, selected: m.selected, + selectedEffort: m.selectedEffort, sessionId: live?.id, running: sessionState == .running, paused: live?.paused ?? false, closed: chat.closed) { + if shown { + shownOverride = false + } else { + reveal() } + } choose: { option in + pick = option + modelChoices[m.modelKey] = option + } chooseEffort: { option in + effortPick = option + effortChoices[m.modelKey] = option + } submit: { + submit(modelState()) } + .equatable() } .padding(.horizontal, 16) .padding(.vertical, 12) @@ -748,95 +864,6 @@ struct ComposerView: View { } } - private func controls(_ m: ModelState, stacked: Bool = false) -> some View { - (stacked ? AnyLayout(VStackLayout(alignment: .leading, spacing: 8)) : AnyLayout(HStackLayout(spacing: 8))) { - Button { - if shown { - shownOverride = false - } else { - reveal() - } - } label: { - Image(systemName: shown ? "chevron.down" : "chevron.up") - .frame(height: 14) - } - .buttonStyle(WebButtonStyle()) - .help(shown ? "Hide the message box" : "Show the message box") - #if os(iOS) - ComposerMenu(key: draftKey, photos: shown, models: chat.team ? [] : m.options, selected: m.selected, effort: m.selectedEffort) { option in - pick = option - modelChoices[m.modelKey] = option - } chooseEffort: { option in - effortPick = option - effortChoices[m.modelKey] = option - } - #else - if !chat.team { - Picker("", selection: Binding(get: { m.selected }, set: { v in - pick = v - modelChoices[m.modelKey] = v - })) { - ForEach(m.options, id: \.self) { option in - Text(option).tag(option) - } - } - .labelsHidden() - .pickerStyle(.menu) - .fixedSize() - .help("Model for the next turn") - Picker("", selection: Binding(get: { m.selectedEffort }, set: { v in - effortPick = v - effortChoices[m.modelKey] = v - })) { - ForEach(efforts(m.selected), id: \.self) { option in - Text(effortLabel(option)).tag(option) - } - } - .labelsHidden() - .pickerStyle(.menu) - .fixedSize() - .help("Effort for the next turn") - } - #endif - } - } - - private func actions(_ sessionId: String?, running: Bool, paused: Bool) -> some View { - HStack(spacing: 8) { - Button { - if let sessionId { - chat.interrupt(sessionId, overlays) - } - } label: { - Image(systemName: "stop.fill") - .frame(width: 14, height: 14) - } - .buttonStyle(WebButtonStyle(danger: true)) - .disabled(!running) - .help("Interrupt the agent's turn") - if case .task = chat.kind, !chat.team, !chat.closed { - Button { - if let sessionId { - chat.setPaused(sessionId, !paused, overlays) - } - } label: { - Image(systemName: paused ? "play.fill" : "pause.fill") - .frame(width: 14, height: 14) - } - .buttonStyle(WebButtonStyle()) - .disabled(sessionId == nil) - .help(paused ? "Resume the agent" : "Pause the agent after its current step") - } - Button(chat.closed ? "Continue" : "Send") { - submit(modelState()) - } - .buttonStyle(WebButtonStyle()) - .disabled(!shown) - .fixedSize() - .help(chat.closed ? "Start a new task from the default branch whose agent continues this conversation with your message" : "Send the message") - } - } - private var editor: some View { AgentEditor(text: $text, placeholder: chat.placeholder, key: draftKey, height: touchOnly ? 72 : 104, focus: $focused, border: inTask ? accent : focused ? Palette.accent : Palette.border,