2026-10-16 22:23:31 -07:00
|
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
|
|
|
|
struct Row: Identifiable {
|
|
|
|
|
|
let id: Int64
|
|
|
|
|
|
let kind: String
|
|
|
|
|
|
var text: String
|
|
|
|
|
|
var tools: [String] = []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-16 22:46:54 -07:00
|
|
|
|
func buildRows(_ events: [ByreEvent]) -> [Row] {
|
2026-10-16 22:23:31 -07:00
|
|
|
|
var rows: [Row] = []
|
|
|
|
|
|
for e in events {
|
|
|
|
|
|
switch e.kind {
|
|
|
|
|
|
case "user.message", "agent.message", "agent.thinking":
|
2026-10-16 22:46:54 -07:00
|
|
|
|
rows.append(Row(id: e.seq, kind: e.kind, text: e.payload.value["text"] as? String ?? ""))
|
2026-10-16 22:23:31 -07:00
|
|
|
|
case "agent.tool_call":
|
2026-10-16 22:46:54 -07:00
|
|
|
|
let name = e.payload.value["name"] as? String ?? "?"
|
|
|
|
|
|
let input = e.payload.value["input"] as? [String: (any Sendable)?]
|
|
|
|
|
|
let detail = input?["file_path"] as? String ?? input?["command"] as? String ?? input?["pattern"] as? String ?? ""
|
2026-10-16 22:23:31 -07:00
|
|
|
|
let line = "\(name) \(detail)"
|
|
|
|
|
|
if rows.last?.kind == "tools" {
|
|
|
|
|
|
rows[rows.count - 1].tools.append(line)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
rows.append(Row(id: e.seq, kind: "tools", text: "", tools: [line]))
|
|
|
|
|
|
}
|
|
|
|
|
|
default:
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return rows
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func toolSummary(_ tools: [String]) -> String {
|
|
|
|
|
|
var order: [String] = []
|
|
|
|
|
|
var counts: [String: Int] = [:]
|
|
|
|
|
|
for t in tools {
|
|
|
|
|
|
let name = String(t.split(separator: " ").first ?? "")
|
|
|
|
|
|
if counts[name] == nil {
|
|
|
|
|
|
order.append(name)
|
|
|
|
|
|
}
|
|
|
|
|
|
counts[name, default: 0] += 1
|
|
|
|
|
|
}
|
|
|
|
|
|
return order.map { counts[$0]! > 1 ? "\($0) ×\(counts[$0]!)" : $0 }.joined(separator: ", ")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
struct RowView: View {
|
|
|
|
|
|
let row: Row
|
|
|
|
|
|
@ViewState private var open = false
|
|
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
|
switch row.kind {
|
|
|
|
|
|
case "tools":
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
|
|
|
|
Button((open ? "▾ " : "▸ ") + toolSummary(row.tools)) {
|
|
|
|
|
|
open.toggle()
|
|
|
|
|
|
}
|
|
|
|
|
|
.buttonStyle(.plain)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
if open {
|
|
|
|
|
|
ForEach(Array(row.tools.enumerated()), id: \.offset) { _, t in
|
|
|
|
|
|
Text(t)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
.textSelection(.enabled)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
case "user.message":
|
|
|
|
|
|
Text(markdown(row.text))
|
|
|
|
|
|
.textSelection(.enabled)
|
|
|
|
|
|
.padding(8)
|
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
|
.background(Color.accentColor.opacity(0.12))
|
|
|
|
|
|
case "agent.thinking":
|
|
|
|
|
|
Text(row.text)
|
|
|
|
|
|
.italic()
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
.textSelection(.enabled)
|
|
|
|
|
|
default:
|
|
|
|
|
|
Text(markdown(row.text))
|
|
|
|
|
|
.textSelection(.enabled)
|
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func markdown(_ s: String) -> AttributedString {
|
|
|
|
|
|
(try? AttributedString(markdown: s, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(s)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
enum Container: String, CaseIterable {
|
|
|
|
|
|
case lazyVStack = "ScrollView + LazyVStack"
|
|
|
|
|
|
case list = "List"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
struct TranscriptSpike: View {
|
2026-10-16 22:46:54 -07:00
|
|
|
|
@Environment(Store.self) private var store
|
2026-10-16 22:23:31 -07:00
|
|
|
|
@ViewState private var all: [Row] = []
|
|
|
|
|
|
@ViewState private var shown = 0
|
|
|
|
|
|
@ViewState private var container = Container.lazyVStack
|
|
|
|
|
|
@ViewState private var playing = false
|
|
|
|
|
|
@ViewState private var streaming = false
|
|
|
|
|
|
@ViewState private var pinned = true
|
|
|
|
|
|
@ViewState private var distance: CGFloat = 0
|
|
|
|
|
|
@ViewState private var tick = 0
|
|
|
|
|
|
@ViewState private var position = ScrollPosition(edge: .bottom)
|
|
|
|
|
|
@ViewState private var events = 0
|
|
|
|
|
|
@ViewState private var loadTime = ""
|
|
|
|
|
|
|
|
|
|
|
|
private var rows: ArraySlice<Row> {
|
|
|
|
|
|
all.prefix(shown)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
|
VStack(alignment: .leading) {
|
|
|
|
|
|
HStack {
|
|
|
|
|
|
Picker("", selection: $container) {
|
|
|
|
|
|
ForEach(Container.allCases, id: \.self) { c in
|
|
|
|
|
|
Text(c.rawValue).tag(c)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.pickerStyle(.segmented)
|
|
|
|
|
|
.frame(width: 320)
|
|
|
|
|
|
Toggle("Play held-back rows", isOn: $playing)
|
|
|
|
|
|
Toggle("Stream a message", isOn: $streaming)
|
|
|
|
|
|
Text("\(events) events, \(shown)/\(all.count) rows \(loadTime) · \(pinned ? "stuck to bottom" : "scrolled up") · \(Int(distance)) pt from bottom")
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
}
|
|
|
|
|
|
switch container {
|
|
|
|
|
|
case .lazyVStack:
|
|
|
|
|
|
ScrollView {
|
|
|
|
|
|
LazyVStack(alignment: .leading, spacing: 10) {
|
|
|
|
|
|
ForEach(rows) { r in
|
|
|
|
|
|
RowView(row: r)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.scrollTargetLayout()
|
|
|
|
|
|
.padding(.horizontal, 8)
|
|
|
|
|
|
}
|
|
|
|
|
|
.scrollPosition($position)
|
|
|
|
|
|
.defaultScrollAnchor(.bottom)
|
|
|
|
|
|
.onScrollGeometryChange(for: CGFloat.self) { g in
|
|
|
|
|
|
g.contentSize.height - g.visibleRect.maxY
|
|
|
|
|
|
} action: { _, d in
|
|
|
|
|
|
distance = d
|
|
|
|
|
|
pinned = d <= 80
|
|
|
|
|
|
}
|
|
|
|
|
|
.onChange(of: tick) {
|
|
|
|
|
|
if pinned {
|
|
|
|
|
|
position.scrollTo(edge: .bottom)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
case .list:
|
|
|
|
|
|
ScrollViewReader { proxy in
|
|
|
|
|
|
List(rows) { r in
|
|
|
|
|
|
RowView(row: r)
|
|
|
|
|
|
}
|
|
|
|
|
|
.onScrollGeometryChange(for: CGFloat.self) { g in
|
|
|
|
|
|
g.contentSize.height - g.visibleRect.maxY
|
|
|
|
|
|
} action: { _, d in
|
|
|
|
|
|
distance = d
|
|
|
|
|
|
pinned = d <= 80
|
|
|
|
|
|
}
|
|
|
|
|
|
.onChange(of: tick) {
|
|
|
|
|
|
if pinned, let last = rows.last {
|
|
|
|
|
|
proxy.scrollTo(last.id, anchor: .bottom)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.onAppear {
|
|
|
|
|
|
if let last = rows.last {
|
|
|
|
|
|
proxy.scrollTo(last.id, anchor: .bottom)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-16 22:46:54 -07:00
|
|
|
|
.onChange(of: store.transcript.count, initial: true) {
|
|
|
|
|
|
load()
|
2026-10-16 22:23:31 -07:00
|
|
|
|
}
|
|
|
|
|
|
.task(id: playing) {
|
|
|
|
|
|
while playing && shown < all.count {
|
|
|
|
|
|
shown += 1
|
|
|
|
|
|
tick += 1
|
|
|
|
|
|
try? await Task.sleep(for: .milliseconds(150))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.task(id: streaming) {
|
|
|
|
|
|
await stream()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-16 22:46:54 -07:00
|
|
|
|
private func load() {
|
|
|
|
|
|
guard let feed = store.selectedFeed, feed.ready else {
|
2026-10-16 22:23:31 -07:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
let start = Date()
|
2026-10-16 22:46:54 -07:00
|
|
|
|
let got = feed.events
|
2026-10-16 22:23:31 -07:00
|
|
|
|
events = got.count
|
|
|
|
|
|
all = buildRows(got)
|
|
|
|
|
|
shown = all.count > 200 ? all.count - 100 : all.count
|
|
|
|
|
|
loadTime = String(format: "in %.2fs", Date().timeIntervalSince(start))
|
|
|
|
|
|
tick += 1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func stream() async {
|
|
|
|
|
|
guard streaming, let source = all.filter({ $0.kind == "agent.message" }).max(by: { $0.text.count < $1.text.count }) else {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
let id = (all.last?.id ?? 0) + 1_000_000
|
|
|
|
|
|
all.insert(Row(id: id, kind: "agent.message", text: ""), at: shown)
|
|
|
|
|
|
shown += 1
|
|
|
|
|
|
let index = shown - 1
|
|
|
|
|
|
for word in source.text.split(separator: " ", omittingEmptySubsequences: false) {
|
|
|
|
|
|
if !streaming {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
all[index].text += (all[index].text.isEmpty ? "" : " ") + word
|
|
|
|
|
|
tick += 1
|
|
|
|
|
|
try? await Task.sleep(for: .milliseconds(30))
|
|
|
|
|
|
}
|
|
|
|
|
|
streaming = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|