From 21a30263b60ea8c781c1b3c3ecd7e7ec18133e96 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Fri, 23 Oct 2026 21:33:26 -0700 Subject: [PATCH] Keep active task transcripts built for fast switching --- mac/Sources/Byre/Chat.swift | 49 +++++++++++++++++++++++++-------- mac/Sources/Byre/Composer.swift | 13 ++++++++- mac/Sources/Byre/Feed.swift | 15 ++-------- mac/Sources/Byre/Panes.swift | 49 ++++++++++++++++++++++++++++----- mac/Sources/Byre/Phone.swift | 15 ++++++---- mac/Sources/Byre/Store.swift | 16 ++++------- 6 files changed, 109 insertions(+), 48 deletions(-) diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index 4b8436b..b478b3c 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -53,6 +53,8 @@ final class ChatModel { let store: Store let builder: TranscriptBuilder let member: String? + private let feed: Feed? + private(set) var visible = false @ObservationIgnored private var members: [String: String] = [:] private(set) var scrollRequest: (seq: Int, id: Int?, anchor: UnitPoint)? private(set) var fetching = false @@ -66,6 +68,7 @@ final class ChatModel { @ObservationIgnored private var visibleSummaries: Set = [] @ObservationIgnored private var activeObserver: NotificationCenter.ObservationToken? @ObservationIgnored private var keyObserver: NotificationCenter.ObservationToken? + @ObservationIgnored private var lastEvent: Int64 = 0 @ObservationIgnored private var started = false @ObservationIgnored private var opened = 0 @ObservationIgnored private var settled = false @@ -76,6 +79,10 @@ final class ChatModel { self.store = store let taskId: String? = if case .task(let id) = kind { id } else { nil } member = taskId.flatMap { store.pickedMember($0) } + feed = switch kind { + case .lead: store.leadFeed + case .task(let id): id.map { store.taskFeed($0) } + } let session: () -> AgentSession? = { [weak store] in switch kind { case .lead: store?.currentLead() @@ -107,13 +114,6 @@ final class ChatModel { taskId.flatMap(store.findTask)?.mergeFacts.hasChanges ?? false } - private var feed: Feed? { - switch kind { - case .lead: store.leadFeed - case .task(let id): id != nil ? store.selectedFeed : nil - } - } - var session: AgentSession? { switch kind { case .lead: store.currentLead() @@ -128,7 +128,7 @@ final class ChatModel { var loading: Bool { switch kind { case .lead: store.leadLoading() - case .task: store.transcriptLoading() + case .task: taskId != nil && feed?.ready != true } } @@ -240,6 +240,7 @@ final class ChatModel { off() } offs = [] + started = false if let activeObserver { NotificationCenter.default.removeObserver(activeObserver) } @@ -253,7 +254,11 @@ final class ChatModel { } private func appended(_ all: [ByreEvent]) { - _ = builder.append(all.filter(shows)) + let fresh = all.filter { $0.seq > lastEvent } + if let last = fresh.last { + lastEvent = last.seq + } + _ = builder.append(fresh.filter(shows)) if !settled { opened = Row.newest settled = feed?.ready == true @@ -262,6 +267,7 @@ final class ChatModel { } func takeFocus() -> Bool { + guard visible else { return false } let asked = taskId != nil && store.composerFocus == taskId if asked { store.composerFocus = nil @@ -281,7 +287,7 @@ final class ChatModel { } private func markShown() { - guard let taskId, store.paneFocus.taskVisible else { + guard visible, let taskId, store.paneFocus.taskVisible else { return } for seq in visibleSummaries { @@ -289,7 +295,20 @@ final class ChatModel { } } + func setVisible(_ value: Bool) { + visible = value + if value { + opened = Row.newest + released = nil + stuck = true + requestScroll(held, held == nil ? .bottom : .top) + jump() + markShown() + } + } + func jump() { + guard visible else { return } guard let j = store.jump, j.taskId == taskId else { return } @@ -588,6 +607,7 @@ final class ChatModel { struct ChatView: View { let kind: ChatKind + var visible = true @Environment(Store.self) private var store @ViewState private var model: ChatModel? @@ -607,10 +627,15 @@ struct ChatView: View { let m = model ?? ChatModel(kind, store) model = m m.start() + m.setVisible(visible) } .onDisappear { + model?.setVisible(false) model?.stop() } + .onChange(of: visible) { + model?.setVisible(visible) + } .onChange(of: store.jump) { model?.jump() } @@ -895,7 +920,7 @@ private struct TranscriptScroll: View { var body: some View { let running = model.running - let active = running || model.reviewing + let active = model.visible && (running || model.reviewing) ScrollView { VStack(alignment: .leading, spacing: 0) { if model.key == nil { @@ -937,7 +962,7 @@ private struct TranscriptScroll: View { } .scrollTargetLayout() .overlay(alignment: .bottomLeading) { - if running && !model.paused && model.key != nil { + if model.visible && running && !model.paused && model.key != nil { Scanner(color: accent) .frame(width: 84, height: 2) .offset(y: 8) diff --git a/mac/Sources/Byre/Composer.swift b/mac/Sources/Byre/Composer.swift index ec6a890..99dd949 100644 --- a/mac/Sources/Byre/Composer.swift +++ b/mac/Sources/Byre/Composer.swift @@ -821,6 +821,17 @@ struct ComposerView: View { text = store.drafts?.composerText(draftKey) ?? "" takeFocus() } + .onChange(of: chat.visible) { + if chat.visible { + takeFocus() + reportDraft() + } else { + focused = false + if inTask { + store.paneFocus.setDraft(draftKey, false) + } + } + } .onChange(of: store.composerFocus) { takeFocus() } @@ -875,7 +886,7 @@ struct ComposerView: View { private func reportDraft() { if inTask { - store.paneFocus.setDraft(draftKey, !trimmed(text).isEmpty) + store.paneFocus.setDraft(draftKey, chat.visible && !trimmed(text).isEmpty) } } diff --git a/mac/Sources/Byre/Feed.swift b/mac/Sources/Byre/Feed.swift index a454214..8d9d83b 100644 --- a/mac/Sources/Byre/Feed.swift +++ b/mac/Sources/Byre/Feed.swift @@ -41,15 +41,10 @@ final class Feed { @ObservationIgnored private var buffer: [ByreEvent] = [] @ObservationIgnored private var scheduled = false @ObservationIgnored private var toolNames: [String: String] = [:] - @ObservationIgnored private let shown: () -> Bool @ObservationIgnored private var persist: ([ByreEvent]) -> Void = { _ in } - init(shown: @escaping () -> Bool) { - self.shown = shown - } - func push(_ ev: ByreEvent) { - if ev.seq == 0 && (!ready || !shown()) { + if ev.seq == 0 && !ready { return } buffer.append(ev) @@ -116,9 +111,7 @@ final class Feed { } append(stored) stored = [] - if shown() { - streamed.send(ev) - } + streamed.send(ev) } append(stored) } @@ -138,8 +131,6 @@ final class Feed { if !found.isEmpty { captures.append(contentsOf: found) } - if shown() { - appended.send(fresh) - } + appended.send(fresh) } } diff --git a/mac/Sources/Byre/Panes.swift b/mac/Sources/Byre/Panes.swift index 2cb0a9e..92d4d4e 100644 --- a/mac/Sources/Byre/Panes.swift +++ b/mac/Sources/Byre/Panes.swift @@ -170,14 +170,19 @@ private struct TaskPane: View { VStack(spacing: 0) { TaskBar() VStack(spacing: 0) { - if overlays.taskList { - TaskList() - } else if store.selectedTaskId == nil { - ProjectView() - } else { + if !overlays.taskList && store.selectedTaskId != nil { TabStrip() - TaskTabBody(tab: store.primaryTab) - .frame(maxWidth: .infinity, maxHeight: .infinity) + } + ZStack { + TaskTranscripts(visible: !overlays.taskList && store.selectedTaskId != nil && store.primaryTab == .transcript) + if overlays.taskList { + TaskList() + } else if store.selectedTaskId == nil { + ProjectView() + } else if store.primaryTab != .transcript { + TaskTabBody(tab: store.primaryTab) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } } .overlay { @@ -208,6 +213,36 @@ private struct TaskPane: View { } #endif +// Keep active transcripts mounted at the pane's full size, including while another +// task or the project dashboard is showing. Switching only changes visibility. +struct TaskTranscripts: View { + var visible = true + @Environment(Store.self) private var store + + private var ids: [String] { + var ids = store.activeTasks().map { $0.task.id } + if let selected = store.selectedTaskId, !ids.contains(selected) { + ids.append(selected) + } + return ids.sorted() + } + + var body: some View { + ZStack { + ForEach(ids, id: \.self) { id in + let shown = visible && store.selectedTaskId == id + ChatView(kind: .task(id), visible: shown) + .id("\(id)/\(store.pickedMember(id) ?? "")") + .environment(\.paneAccent, store.findTask(id).map { taskPalette[$0.color] } ?? Palette.accent) + .opacity(shown ? 1 : 0) + .allowsHitTesting(shown) + .accessibilityHidden(!shown) + .disabled(!shown) + } + } + } +} + struct TaskTabBody: View { let tab: TaskTab @Environment(Store.self) private var store diff --git a/mac/Sources/Byre/Phone.swift b/mac/Sources/Byre/Phone.swift index 9fd0b47..3c0c3e4 100644 --- a/mac/Sources/Byre/Phone.swift +++ b/mac/Sources/Byre/Phone.swift @@ -310,12 +310,17 @@ private struct PhoneTask: View { } .buttonStyle(.plain) } - if store.selectedTaskId == nil { - ProjectView() - } else { + if store.selectedTaskId != nil { TabStrip() - TaskTabBody(tab: tab) - .frame(maxWidth: .infinity, maxHeight: .infinity) + } + ZStack { + TaskTranscripts(visible: store.selectedTaskId != nil && tab == .transcript) + if store.selectedTaskId == nil { + ProjectView() + } else if tab != .transcript { + TaskTabBody(tab: tab) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } } .background(Palette.bg) diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift index aee7c74..8a2ab92 100644 --- a/mac/Sources/Byre/Store.swift +++ b/mac/Sources/Byre/Store.swift @@ -129,7 +129,7 @@ final class Store { self.forwards = forwards self.pages = pages self.cache = cache - leadFeed = Feed(shown: { true }) + leadFeed = Feed() paneFocus = PaneFocus(api: api) remote = Remote(api: api) remote.store = self @@ -482,7 +482,7 @@ final class Store { private func applyTasks(_ projectId: String, _ list: [ByreTask]) async throws { tasks[projectId] = list.sorted { $0.createdAt > $1.createdAt } if feedsStarted { - for t in list where t.status == .active && !t.background { + for t in list where t.status == .active { _ = taskFeed(t.id) } } @@ -666,15 +666,13 @@ final class Store { feedsStarted = true let active = activeTasks() cache.keep(Set(active.map(\.task.id))) - for (_, task) in active where !task.background { + for (_, task) in active { _ = taskFeed(task.id) } } - private func taskFeed(_ taskId: String) -> Feed { - let feed = taskFeeds[taskId] ?? Feed(shown: { [weak self] in - self?.selectedTaskId == taskId - }) + func taskFeed(_ taskId: String) -> Feed { + let feed = taskFeeds[taskId] ?? Feed() taskFeeds[taskId] = feed let key = ObjectIdentifier(feed) if feed.ready || historyLoads.contains(key) { @@ -701,10 +699,6 @@ final class Store { } } - func transcriptLoading() -> Bool { - selectedTaskId != nil && selectedFeed?.ready != true - } - func leadLoading() -> Bool { !leadFeed.ready }