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
@@ -0,0 +1,237 @@
|
||||
// All threads, opened from the last row of the feed: hosts, then a host's projects sorted by title,
|
||||
// then a project's threads with settled ones included. Hosts and projects come from
|
||||
// `GET /v1/projects`, threads from `GET /v1/projects/:hostId/:projectId/threads`. Each screen loads
|
||||
// when it appears; nothing polls. A thread opens the usual thread screen.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Every host, offline ones dimmed. Each opens its projects.
|
||||
struct HostsView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
/// Snapshots render the rows without their List, which ImageRenderer leaves blank.
|
||||
var scrolls = true
|
||||
|
||||
var body: some View {
|
||||
BrowseList(scrolls: scrolls, error: model.projectsError, loaded: model.projects != nil, reload: model.loadProjects) {
|
||||
if let hosts = model.projects?.hosts {
|
||||
ForEach(hosts) { host in
|
||||
BrowseRow(
|
||||
title: host.label,
|
||||
subtitle: host.status.problem ?? (host.projects.count == 1 ? "1 project" : "\(host.projects.count) projects"),
|
||||
route: .hostProjects(hostId: host.id))
|
||||
.opacity(host.status == .online ? 1 : 0.5)
|
||||
.feedRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("All threads")
|
||||
}
|
||||
}
|
||||
|
||||
/// One host's projects, sorted by title. Each opens its threads.
|
||||
struct ProjectsView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
let hostId: String
|
||||
var scrolls = true
|
||||
|
||||
var body: some View {
|
||||
let host = model.projects?.hosts.first { $0.id == hostId }
|
||||
BrowseList(scrolls: scrolls, error: model.projectsError, loaded: model.projects != nil, reload: model.loadProjects) {
|
||||
if let host {
|
||||
let projects = host.projects.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
|
||||
ForEach(projects) { project in
|
||||
BrowseRow(title: project.title, route: .projectThreads(hostId: host.id, projectId: project.id))
|
||||
.feedRow()
|
||||
}
|
||||
if projects.isEmpty {
|
||||
BrowseNote(text: "No projects.")
|
||||
}
|
||||
} else if model.projects != nil {
|
||||
BrowseNote(text: "This host is gone.")
|
||||
}
|
||||
}
|
||||
.navigationTitle(host?.label ?? "Projects")
|
||||
}
|
||||
}
|
||||
|
||||
/// One project's threads, newest first: a status dot, the title, dimmed once finished or settled,
|
||||
/// and the age.
|
||||
struct ProjectThreadsView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
let hostId: String
|
||||
let projectId: String
|
||||
@State private var rows: [ThreadRow]?
|
||||
@State private var error: String?
|
||||
private let scrolls: Bool
|
||||
|
||||
/// `rows` starts the screen loaded, for snapshots.
|
||||
init(hostId: String, projectId: String, rows: [ThreadRow]? = nil, scrolls: Bool = true) {
|
||||
self.hostId = hostId
|
||||
self.projectId = projectId
|
||||
self.scrolls = scrolls
|
||||
_rows = State(initialValue: rows)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let title = model.projects?.hosts.first { $0.id == hostId }?.projects.first { $0.id == projectId }?.title
|
||||
BrowseList(scrolls: scrolls, error: error, loaded: rows != nil, reload: load) {
|
||||
if let rows {
|
||||
ForEach(rows) { row in
|
||||
ThreadLine(row: row) {
|
||||
Text(Format.elapsed(since: row.updatedAt))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
.feedRow()
|
||||
}
|
||||
if rows.isEmpty {
|
||||
BrowseNote(text: "No threads yet.")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(title ?? "Threads")
|
||||
.task {
|
||||
// The title comes from the projects list, which a wristcode:// link may skip.
|
||||
if model.projects == nil { await model.loadProjects() }
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
do {
|
||||
rows = try await model.api.projectThreads(hostId: hostId, projectId: projectId).items
|
||||
error = nil
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A thread on one line: status dot, title, and `trailing` on the right. Opens the thread.
|
||||
struct ThreadLine<Trailing: View>: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var row: ThreadRow
|
||||
@ViewBuilder var trailing: Trailing
|
||||
|
||||
var body: some View {
|
||||
Button { model.path.append(row.route) } label: {
|
||||
Card(edge: Theme.card) {
|
||||
HStack(spacing: 7) {
|
||||
Circle()
|
||||
.fill(row.color)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(row.title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(row.isActive ? Color.white : Theme.secondaryText)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
trailing
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// The List each browse screen scrolls, with loading, an error and Try again. Loads on appear.
|
||||
private struct BrowseList<Rows: View>: View {
|
||||
var scrolls: Bool
|
||||
var error: String?
|
||||
var loaded: Bool
|
||||
var reload: () async -> Void
|
||||
@ViewBuilder var rows: Rows
|
||||
|
||||
var body: some View {
|
||||
if scrolls {
|
||||
List { content }
|
||||
.listStyle(.plain)
|
||||
.refreshable { await reload() }
|
||||
.task { await reload() }
|
||||
} else {
|
||||
VStack(spacing: 6) { content }
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
if loaded {
|
||||
rows
|
||||
} else if error == nil {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 20)
|
||||
.feedRow()
|
||||
}
|
||||
if let error {
|
||||
BrowseNote(text: error)
|
||||
if !loaded {
|
||||
Button("Try again") { Task { await reload() } }
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
|
||||
.feedRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain card with a title, an optional line under it, and a chevron.
|
||||
private struct BrowseRow: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var title: String
|
||||
var subtitle: String?
|
||||
var route: Route
|
||||
|
||||
var body: some View {
|
||||
Button { model.path.append(route) } label: {
|
||||
Card(edge: Theme.done) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.lineLimit(2)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private struct BrowseNote: View {
|
||||
var text: String
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.horizontal, 8)
|
||||
.feedRow()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Hosts") {
|
||||
NavigationStack {
|
||||
HostsView()
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
.fontDesign(.rounded)
|
||||
}
|
||||
|
||||
#Preview("Threads") {
|
||||
NavigationStack {
|
||||
ProjectThreadsView(hostId: "mac-mini", projectId: "p-wc-mac")
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
.fontDesign(.rounded)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// Mock B's attention feed: the count waiting on you, then cards for every host. Needs you first
|
||||
// (with Allow and Deny on the card), then working (with what the agent is doing), then recent.
|
||||
// A Recent row swipes left to settle, and the Recent header settles every completed thread. Past
|
||||
// Recent's first 10, an "N more" row opens the full Recent list.
|
||||
// (with Allow and Deny on the card), then working (with the step the agent is on and how long it
|
||||
// has run), then recent. A Recent row swipes left to settle, and the Recent header settles every
|
||||
// completed thread. Past Recent's first 10, an "N more" row opens the full Recent list. The last
|
||||
// row opens All threads.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@@ -39,6 +40,7 @@ struct FeedView: View {
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.feedRow()
|
||||
}
|
||||
AllThreadsRow().feedRow()
|
||||
} else if model.feedError != nil {
|
||||
Button("Try again") { Task { await model.refreshFeed() } }
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
|
||||
@@ -207,6 +209,28 @@ struct MoreRecentRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The feed's last row, a plain card opening All threads.
|
||||
struct AllThreadsRow: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
Button { model.path.append(.allThreads) } label: {
|
||||
Card(edge: Theme.done) {
|
||||
HStack {
|
||||
Text("All threads")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Spacer(minLength: 4)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// An amber card for a thread waiting on an approval or an answer, with Allow and Deny inline.
|
||||
/// When a subagent asked, one line names it, and the answer goes to the subagent.
|
||||
struct NeedsYouCard: View {
|
||||
@@ -292,7 +316,8 @@ struct NeedsYouCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// A teal card for a working thread, or a grey one for a recent thread. A recent thread that failed
|
||||
/// A teal card for a working thread, or a grey one for a recent thread. A working thread shows the
|
||||
/// step it is on, or its latest message when the gateway names no step. A recent thread that failed
|
||||
/// to settle shows why.
|
||||
struct StatusCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@@ -310,8 +335,11 @@ struct StatusCard: View {
|
||||
.foregroundStyle(item.phase.isWorking ? Color.white : Theme.secondaryText)
|
||||
.lineLimit(2)
|
||||
.padding(.top, 1)
|
||||
if item.phase.isWorking, let line = item.lastMessage {
|
||||
Text(line)
|
||||
if item.phase.isWorking, let activity = item.activity {
|
||||
ActivityLine(activity: activity)
|
||||
.padding(.top, 3)
|
||||
} else if item.phase.isWorking, let line = item.lastMessage {
|
||||
Text(Markdown.render(line))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.lineLimit(2)
|
||||
@@ -339,6 +367,58 @@ struct StatusCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a working thread is doing: a teal symbol for the kind of step, its text, and how long it has
|
||||
/// run. The elapsed time is worked out when the card renders, so the feed's 10 s refresh moves it on.
|
||||
struct ActivityLine: View {
|
||||
var activity: Activity
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(Theme.work)
|
||||
.frame(width: 14)
|
||||
label
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.lineLimit(2)
|
||||
Spacer(minLength: 4)
|
||||
if let startedAt = activity.startedAt {
|
||||
Text(Format.elapsed(since: startedAt))
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Theme.work)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var label: Text {
|
||||
// Marked as code, which Text sets in monospace. A monospaced Font loses to the app's rounded design.
|
||||
let mono = { (text: String) -> Text in
|
||||
var code = AttributedString(text)
|
||||
code.inlinePresentationIntent = .code
|
||||
return Text(code).font(.system(size: 11))
|
||||
}
|
||||
return switch activity.kind {
|
||||
case .command: mono(activity.text)
|
||||
case .edit: Text("Editing \(mono(activity.text))")
|
||||
default: Text(activity.text)
|
||||
}
|
||||
}
|
||||
|
||||
private var symbol: String {
|
||||
switch activity.kind {
|
||||
case .command: "terminal"
|
||||
case .edit: "pencil"
|
||||
case .search: "magnifyingglass"
|
||||
case .web: "globe"
|
||||
case .subagent: "arrow.triangle.branch"
|
||||
case .thinking: "ellipsis"
|
||||
default: "wrench.adjustable"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Feed") {
|
||||
NavigationStack {
|
||||
FeedView()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Message text as styled text. Inline markdown (bold, italics, inline code, links) goes through
|
||||
// `AttributedString(markdown:)`. Block syntax is rewritten line by line first, since SwiftUI's Text
|
||||
// runs full-parsed paragraphs together: headings turn bold, list markers turn into bullets, quote
|
||||
// markers go, and fenced code keeps its lines in monospace.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum Markdown {
|
||||
static func render(_ source: String) -> AttributedString {
|
||||
var result = AttributedString()
|
||||
var fenced = false
|
||||
var first = true
|
||||
for line in source.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
|
||||
if line.trimmingCharacters(in: .whitespaces).hasPrefix("```") {
|
||||
fenced.toggle()
|
||||
continue
|
||||
}
|
||||
if !first { result += AttributedString("\n") }
|
||||
first = false
|
||||
if fenced {
|
||||
var code = AttributedString(line)
|
||||
code.inlinePresentationIntent = .code
|
||||
result += code
|
||||
} else {
|
||||
result += inline(block(line))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// One line with its block syntax rewritten as inline markdown or plain text.
|
||||
static func block(_ line: String) -> String {
|
||||
if let heading = line.wholeMatch(of: /\s{0,3}#{1,6}\s+(.+?)[\s#]*/) {
|
||||
return "**\(heading.1)**"
|
||||
}
|
||||
if let item = line.wholeMatch(of: /(\s*)[-*+]\s+(.*)/) {
|
||||
return "\(item.1)• \(item.2)"
|
||||
}
|
||||
if let quote = line.wholeMatch(of: /\s{0,3}>\s?(.*)/) {
|
||||
return String(quote.1)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
/// Inline markdown, or the line as plain text when it does not parse.
|
||||
private static func inline(_ line: String) -> AttributedString {
|
||||
let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
return (try? AttributedString(markdown: line, options: options)) ?? AttributedString(line)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,11 @@ extension Phase {
|
||||
}
|
||||
}
|
||||
|
||||
extension ThreadRow {
|
||||
/// The status dot: amber waiting, teal working, red failed, grey otherwise.
|
||||
var color: Color { phase == .failed ? Theme.bad : phase?.color ?? Theme.done }
|
||||
}
|
||||
|
||||
/// A dark rounded card with a 4 pt colored left edge.
|
||||
struct Card<Content: View>: View {
|
||||
var edge: Color
|
||||
@@ -135,6 +140,16 @@ enum Format {
|
||||
return "\(hours / 24) d"
|
||||
}
|
||||
|
||||
/// A compact time since a date, such as "42s", "3m", "1h" or "2d", for an activity's elapsed time
|
||||
/// and a thread row's age.
|
||||
static func elapsed(since date: Date, now: Date = .now) -> String {
|
||||
let seconds = max(0, Int(now.timeIntervalSince(date)))
|
||||
if seconds < 60 { return "\(seconds)s" }
|
||||
if seconds < 3600 { return "\(seconds / 60)m" }
|
||||
if seconds < 86_400 { return "\(seconds / 3600)h" }
|
||||
return "\(seconds / 86_400)d"
|
||||
}
|
||||
|
||||
/// How long until a date, such as "40 s", "3 min" or "2 h".
|
||||
static func countdown(to date: Date, now: Date = .now) -> String {
|
||||
let seconds = max(0, Int(date.timeIntervalSince(now).rounded()))
|
||||
|
||||
@@ -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