Add the macOS client spike and record its SwiftUI gap results

This commit is contained in:
Ian Gulliver
2026-10-16 22:23:31 -07:00
parent 7e0e6d9585
commit c2d0ff77d7
16 changed files with 1281 additions and 20 deletions
+282
View File
@@ -0,0 +1,282 @@
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<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)
}
}
}
}
}
.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
}
}