Keep active task transcripts built for fast switching

This commit is contained in:
Ian Gulliver
2026-10-23 21:33:26 -07:00
parent d13e1f695f
commit 21a30263b6
6 changed files with 109 additions and 48 deletions
+37 -12
View File
@@ -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<Int64> = []
@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)
+12 -1
View File
@@ -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)
}
}
+3 -12
View File
@@ -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)
}
}
+42 -7
View File
@@ -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
+10 -5
View File
@@ -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)
+5 -11
View File
@@ -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
}