Keep active task transcripts built for fast switching
This commit is contained in:
+37
-12
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user