Watch: activity line on working cards, All threads browsing, a thread's subagents, Load earlier, and markdown messages
This commit is contained in:
1 parent
abd2fd0e13
commit
d916bfbab5
21 files changed
+928
-46
No files matched your search
@@ -1,7 +1,8 @@
|
||||
// One thread: the agent's latest message large, older messages one scroll up, the pending approval
|
||||
// or question with its options, Reply by dictation or Scribble, Settle once it finished or failed,
|
||||
// an Ask before acting switch, and Stop while a run is active. A subagent's request shows first,
|
||||
// labeled with the subagent.
|
||||
// One thread: the agent's latest message large, older messages one scroll up with Load earlier above
|
||||
// them, the pending approval or question with its options, the thread's subagents, Reply by
|
||||
// dictation or Scribble, Settle once it finished or failed, an Ask before acting switch, and Stop
|
||||
// while a run is active. A subagent's request shows first, labeled with the subagent. Message text
|
||||
// renders as markdown.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@@ -24,6 +25,9 @@ struct ThreadView: View {
|
||||
@State private var modeError: String?
|
||||
/// The Ask before acting value still being sent, which a poll landing meanwhile keeps.
|
||||
@State private var pendingAskFirst: Bool?
|
||||
/// Pages Load earlier fetched. The 10 s refresh replaces only the latest window.
|
||||
@State private var history = MessageHistory()
|
||||
@State private var loadingEarlier = false
|
||||
|
||||
/// Snapshots render the column without its ScrollView, which ImageRenderer leaves blank.
|
||||
private let scrolls: Bool
|
||||
@@ -98,9 +102,15 @@ struct ThreadView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func content(_ detail: ThreadDetail) -> some View {
|
||||
let split = MessageSplit(detail.messages)
|
||||
let split = MessageSplit(history.messages(before: detail.messages))
|
||||
let item = detail.item
|
||||
|
||||
if let cursor = history.cursor(detail) {
|
||||
Button(loadingEarlier ? "Loading" : "Load earlier") { loadEarlier(cursor) }
|
||||
.buttonStyle(ChoiceButtonStyle(fill: Theme.card, foreground: Theme.secondaryText))
|
||||
.disabled(loadingEarlier)
|
||||
}
|
||||
|
||||
ForEach(split.earlier) { message in
|
||||
MessageRow(message: message)
|
||||
}
|
||||
@@ -112,7 +122,7 @@ struct ThreadView: View {
|
||||
.padding(.top, split.earlier.isEmpty ? 0 : 6)
|
||||
}
|
||||
|
||||
Text(split.latest?.text ?? item.lastMessage ?? "No reply yet.")
|
||||
Text(Markdown.render(split.latest?.text ?? item.lastMessage ?? "No reply yet."))
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(Theme.bodyText)
|
||||
.lineSpacing(1)
|
||||
@@ -132,6 +142,20 @@ struct ThreadView: View {
|
||||
requestCards(request, from: nil)
|
||||
}
|
||||
|
||||
if !detail.subagents.isEmpty {
|
||||
Text("Subagents")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 4)
|
||||
ForEach(detail.subagents) { row in
|
||||
ThreadLine(row: row) {
|
||||
Text(row.statusWord)
|
||||
.foregroundStyle(row.isActive ? row.color : Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if item.phase == .completed && item.request == nil {
|
||||
QuickReplies { send($0) }
|
||||
.disabled(sending)
|
||||
@@ -316,6 +340,7 @@ struct ThreadView: View {
|
||||
do {
|
||||
var fresh = try await model.api.thread(hostId: hostId, threadId: threadId)
|
||||
if let pendingAskFirst { fresh.askFirst = pendingAskFirst }
|
||||
if let detail { history.keep(detail.messages) }
|
||||
detail = fresh
|
||||
error = nil
|
||||
} catch {
|
||||
@@ -323,6 +348,19 @@ struct ThreadView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the page before `cursor` and puts it above the messages already shown.
|
||||
private func loadEarlier(_ cursor: String) {
|
||||
loadingEarlier = true
|
||||
Task {
|
||||
do {
|
||||
history.prepend(try await model.api.history(hostId: hostId, threadId: threadId, cursor: cursor))
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
}
|
||||
loadingEarlier = false
|
||||
}
|
||||
}
|
||||
|
||||
private func perform(_ action: @escaping @MainActor () async throws -> Void) {
|
||||
sending = true
|
||||
error = nil
|
||||
@@ -426,12 +464,50 @@ struct MessageSplit {
|
||||
}
|
||||
}
|
||||
|
||||
/// Older pages fetched by Load earlier, oldest first, kept in front of the latest window the thread
|
||||
/// route returns. Once a page is in, a refresh's outgoing window is kept too, so no gap opens when
|
||||
/// new messages push the window forward. A message shows once, by id, the latest window's copy first.
|
||||
struct MessageHistory {
|
||||
private(set) var older: [Message] = []
|
||||
/// The cursor for the page before `older`, once a page has loaded.
|
||||
private var nextCursor: String?
|
||||
private var loaded = false
|
||||
|
||||
/// Where Load earlier fetches from next, or nil when the thread starts in view.
|
||||
func cursor(_ detail: ThreadDetail) -> String? {
|
||||
loaded ? nextCursor : detail.earlierCursor
|
||||
}
|
||||
|
||||
/// The older pages, then `latest`.
|
||||
func messages(before latest: [Message]) -> [Message] {
|
||||
let ids = Set(latest.map(\.id))
|
||||
return older.filter { !ids.contains($0.id) } + latest
|
||||
}
|
||||
|
||||
mutating func prepend(_ page: HistoryResponse) {
|
||||
older = Self.unique(page.messages + older)
|
||||
nextCursor = page.earlierCursor
|
||||
loaded = true
|
||||
}
|
||||
|
||||
/// Keeps the window a refresh replaces, once older pages are in.
|
||||
mutating func keep(_ window: [Message]) {
|
||||
guard loaded else { return }
|
||||
older = Self.unique(older + window)
|
||||
}
|
||||
|
||||
private static func unique(_ messages: [Message]) -> [Message] {
|
||||
var seen = Set<String>()
|
||||
return messages.filter { seen.insert($0.id).inserted }
|
||||
}
|
||||
}
|
||||
|
||||
private struct MessageRow: View {
|
||||
var message: Message
|
||||
|
||||
var body: some View {
|
||||
let mine = message.role == .user
|
||||
Text(message.text)
|
||||
Text(Markdown.render(message.text))
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(mine ? Color.white : Theme.secondaryText)
|
||||
.padding(.horizontal, mine ? 9 : 0)
|
||||
|
||||
Reference in new issue
Block a user