import SwiftUI enum JSON: Decodable { case string(String) case number(Double) case bool(Bool) case object([String: JSON]) case array([JSON]) case null init(from decoder: Decoder) throws { let c = try decoder.singleValueContainer() if c.decodeNil() { self = .null } else if let v = try? c.decode(Bool.self) { self = .bool(v) } else if let v = try? c.decode(Double.self) { self = .number(v) } else if let v = try? c.decode(String.self) { self = .string(v) } else if let v = try? c.decode([JSON].self) { self = .array(v) } else { self = .object(try c.decode([String: JSON].self)) } } subscript(key: String) -> JSON? { if case .object(let o) = self { return o[key] } return nil } var string: String? { if case .string(let s) = self { return s } return nil } } struct Event: Decodable { let seq: Int64 let kind: String let payload: JSON } struct Row: Identifiable { let id: Int64 let kind: String var text: String var tools: [String] = [] } func buildRows(_ events: [Event]) -> [Row] { var rows: [Row] = [] for e in events { switch e.kind { case "user.message", "agent.message", "agent.thinking": rows.append(Row(id: e.seq, kind: e.kind, text: e.payload["text"]?.string ?? "")) case "agent.tool_call": let name = e.payload["name"]?.string ?? "?" let input = e.payload["input"] let detail = input?["file_path"]?.string ?? input?["command"]?.string ?? input?["pattern"]?.string ?? "" 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 { @Environment(Server.self) private var server @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 { 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) } } } } } .task { await load() } .task(id: playing) { while playing && shown < all.count { shown += 1 tick += 1 try? await Task.sleep(for: .milliseconds(150)) } } .task(id: streaming) { await stream() } } private func load() async { guard !server.taskId.isEmpty else { return } let start = Date() var got: [Event] = [] var after: Int64 = 0 do { while true { let page = try await server.get([Event].self, "/api/tasks/\(server.taskId)/events", [URLQueryItem(name: "after", value: String(after)), URLQueryItem(name: "limit", value: "1000")]) got += page if page.count < 1000 { break } after = page.last!.seq } } catch { server.log("transcript: \(error)") return } 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 } }