Carry the source transcript into forks and cross-link fork points

This commit is contained in:
Ian Gulliver
2026-10-20 09:14:44 -07:00
parent 29c993cde0
commit 09d6c6e023
10 changed files with 208 additions and 123 deletions
+39 -26
View File
@@ -268,7 +268,7 @@ final class ChatModel {
store.jump = nil
scrollTo(row.id)
case .review(let n):
guard let row = reviewRow(n) else {
guard let row = reviewRow(n, taskId) else {
return
}
store.jump = nil
@@ -276,8 +276,8 @@ final class ChatModel {
}
}
private func reviewRow(_ n: Int) -> Row? {
let rows = builder.root.rows
private func reviewRow(_ n: Int, _ task: String?) -> Row? {
let rows = builder.root.rows.filter { $0.taskId == task }
return rows.first { $0.reviewOf == n } ?? rows.first { r in
if case .snapshot(let s) = r.kind {
return s.n == n
@@ -286,8 +286,8 @@ final class ChatModel {
}
}
func showReview(_ n: Int) {
if let row = reviewRow(n) {
func showReview(_ n: Int, _ task: String?) {
if let row = reviewRow(n, task) {
scrollTo(row.id)
}
}
@@ -317,7 +317,7 @@ final class ChatModel {
var discard = false
for row in builder.root.rows where !row.hidden {
switch row.kind {
case .question where row.question?.state == .open:
case .question where row.question?.state == .open && row.taskId == taskId:
questions.append(row.id)
case .finding(let i):
if let r = row.review, r.id == latest?.id, findingOpen(r, i) {
@@ -1300,13 +1300,13 @@ struct RowView: View {
}
case .snapshot(let snap):
SnapshotRow(snap: snap, review: row.review, taskId: row.taskId) {
model.showReview(snap.n)
SnapshotRow(snap: snap, review: row.review, taskId: row.taskId, inherited: row.taskId != model.taskId, forks: row.forks) {
model.showReview(snap.n, row.taskId)
}
case .question:
if let item = row.question {
QuestionBubble(item: item)
QuestionBubble(item: item, inherited: row.taskId != model.taskId)
}
}
}
@@ -1314,9 +1314,10 @@ struct RowView: View {
private struct QuestionBubble: View {
let item: InboxItem
let inherited: Bool
var body: some View {
let open = item.state == .open
let open = item.state == .open && !inherited
Group {
if open {
QuestionCard(item: item)
@@ -1357,7 +1358,7 @@ private struct QuestionBubble: View {
let answer = item.responses.last
switch item.state {
case .open:
return "waiting for your answer"
return inherited ? "unanswered when this task was forked" : "waiting for your answer"
case .dismissed:
return item.resolvedBy == nil ? "withdrawn by the agent" : "dismissed by \(item.resolvedBy!)"
default:
@@ -1594,16 +1595,7 @@ struct NoteView: View {
case .text(let s):
out += AttributedString(s)
case .task(let id):
guard let task = store.findTask(id) else {
out += AttributedString(id)
continue
}
var a = AttributedString(task.name)
if task.status == .active {
a.link = AppLink.task(task.id)
a.foregroundColor = taskPalette[task.color]
}
out += a
out += taskLink(store, id)
case .url(let port):
let url = previewURL(store.api.origin, Int(port) ?? 0)
var a = AttributedString(url.absoluteString)
@@ -1616,6 +1608,19 @@ struct NoteView: View {
}
}
@MainActor
func taskLink(_ store: Store, _ id: String) -> AttributedString {
guard let task = store.findTask(id) else {
return AttributedString(id)
}
var a = AttributedString(task.name)
if task.status == .active {
a.link = AppLink.task(task.id)
a.foregroundColor = taskPalette[task.color]
}
return a
}
func previewURL(_ origin: URL, _ port: Int) -> URL {
var c = URLComponents()
c.scheme = "https"
@@ -1629,6 +1634,8 @@ private struct SnapshotRow: View {
let snap: Snapshot
let review: Review?
let taskId: String?
let inherited: Bool
let forks: [String]
let showReview: () -> Void
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@@ -1640,12 +1647,14 @@ private struct SnapshotRow: View {
HStack(spacing: 8) {
Rectangle().fill(Palette.border).frame(height: 1)
Text("snapshot #\(snap.n)")
Button("changes") {
store.pinSnapshot(snap.n)
store.focusTab(.changes)
if !inherited {
Button("changes") {
store.pinSnapshot(snap.n)
store.focusTab(.changes)
}
.buttonStyle(SmallLinkButtonStyle())
.help("Show snapshot \(snap.n) in the Changes tab")
}
.buttonStyle(SmallLinkButtonStyle())
.help("Show snapshot \(snap.n) in the Changes tab")
Button("fork") {
guard let taskId else {
return
@@ -1667,6 +1676,10 @@ private struct SnapshotRow: View {
if ![.answer, .fork, .askHuman].contains(snap.trigger) {
reviewButton
}
ForEach(forks, id: \.self) { id in
Text(AttributedString("forked as ") + taskLink(store, id))
.fixedSize()
}
Rectangle().fill(Palette.border).frame(height: 1)
}
.font(Fonts.font(11))
+7 -1
View File
@@ -25,6 +25,7 @@ final class Listeners<T> {
final class Feed {
private(set) var events: [ByreEvent] = []
private(set) var ready = false
private(set) var inherited = 0
let appended = Listeners<[ByreEvent]>()
let streamed = Listeners<ByreEvent>()
@ObservationIgnored private var last: Int64 = 0
@@ -49,15 +50,20 @@ final class Feed {
}
}
func load(_ history: [ByreEvent]) {
func load(_ history: [ByreEvent], inherited: Int = 0) {
if ready {
return
}
ready = true
self.inherited = inherited
buffer = history + buffer
flush()
}
var own: ArraySlice<ByreEvent> {
events.dropFirst(inherited)
}
func prepend(_ older: [ByreEvent]) {
events.insert(contentsOf: older, at: 0)
}
+3 -3
View File
@@ -164,8 +164,8 @@ final class Store {
}
}
var transcript: [ByreEvent] {
selectedFeed?.events ?? []
var transcript: ArraySlice<ByreEvent> {
selectedFeed?.own ?? []
}
var selectedFeed: Feed? {
@@ -439,7 +439,7 @@ final class Store {
break
}
}
feed.load(got)
feed.load(got, inherited: got.prefix { $0.taskId != taskId }.count)
}
private func taskFeed(_ taskId: String) -> Feed {
+62 -39
View File
@@ -87,6 +87,7 @@ final class Row: Identifiable {
var summary: Note?
var review: Review?
var reviewOf: Int?
var forks: [String] = []
var question: InboxItem?
var text = ""
var md: [MDBlock] = []
@@ -177,8 +178,8 @@ private final class Ctx {
var tools: [String: Row] = [:]
var subagents: [String: Subagent] = [:]
var reviewers: Set<String> = []
var reviewButtons: [Int: Row] = [:]
var reviews: [Int: Review] = [:]
var snapshots: [String: Row] = [:]
var reviews: [String: Review] = [:]
var reviewRows: [String: [Row]] = [:]
var questions: [String: Row] = [:]
var run: Run?
@@ -204,12 +205,14 @@ final class TranscriptBuilder {
@ObservationIgnored private var stream: Row?
@ObservationIgnored private var streamTimer: Task<Void, Never>?
@ObservationIgnored private let taskId: String?
@ObservationIgnored private var rowTask: String?
@ObservationIgnored private let worktree: () -> String
@ObservationIgnored private let running: () -> Bool
@ObservationIgnored private var pendingIds = 0
init(taskId: String?, worktree: @escaping () -> String, running: @escaping () -> Bool) {
self.taskId = taskId
rowTask = taskId
self.worktree = worktree
self.running = running
}
@@ -317,7 +320,7 @@ final class TranscriptBuilder {
}
run.calls.append(call)
if run.group == nil {
let group = Row(.group, taskId: taskId, body: true)
let group = Row(.group, taskId: rowTask, body: true)
group.owner = ctx.subagents[parent]?.row
for list in [run.container, sink ?? root] {
if let i = list.rows.firstIndex(where: { $0 === run.first }) {
@@ -356,7 +359,7 @@ final class TranscriptBuilder {
if let stream {
row = stream
} else {
row = Row(.stream, taskId: taskId)
row = Row(.stream, taskId: rowTask)
row.hidden = true
stream = row
ctx.run = nil
@@ -378,7 +381,7 @@ final class TranscriptBuilder {
}
private func openHandoff() {
let row = Row(.handoff, taskId: taskId, body: true)
let row = Row(.handoff, taskId: rowTask, body: true)
row.summary = Note(parts: [.text("writing a handoff…")])
put(row)
ctx.handoff = row
@@ -398,9 +401,14 @@ final class TranscriptBuilder {
private func render(_ events: [ByreEvent]) -> Bool {
var contextChanged = false
defer {
rowTask = taskId
}
for ev in events {
let p = ev.payload.value
let parent = parentOf(p)
rowTask = ev.taskId ?? taskId
let own = rowTask == taskId
if ev.kind == "session.created" && str(p, "role") == "reviewer", let sid = ev.agentSessionId, !sid.isEmpty {
ctx.reviewers.insert(sid)
continue
@@ -423,14 +431,14 @@ final class TranscriptBuilder {
if sink == nil && ((ev.kind == "agent.message" && parent.isEmpty) || ev.kind == "turn.ended" || ev.kind == "user.message") {
dropStream()
}
if sink == nil && ev.kind == "user.message" {
if sink == nil && own && ev.kind == "user.message" {
confirmPending(str(p, "text") ?? "", queued: true, local: ev.actor != "byre")
}
if sink == nil && (ev.kind == "task.merged" || ev.kind == "task.discarded") {
if sink == nil && own && (ev.kind == "task.merged" || ev.kind == "task.discarded") {
pending.removeAll { !$0.local }
}
if ev.kind == "user.queued" {
if sink == nil {
if sink == nil && own {
let text = str(p, "text") ?? ""
confirmPending(text, queued: false, local: true)
pendingIds += 1
@@ -440,7 +448,7 @@ final class TranscriptBuilder {
continue
}
if ev.kind == "user.unqueued" {
if sink == nil, let id = int(p, "id") {
if sink == nil && own, let id = int(p, "id") {
withdrawn(id)
}
continue
@@ -465,18 +473,26 @@ final class TranscriptBuilder {
if ev.kind == "snapshot.created" {
ctx.run = nil
let snap = try! decodePayload(ev.payload, as: Snapshot.self)
let row = Row(.snapshot(snap), taskId: taskId)
ctx.reviewButtons[snap.n] = row
if ![.answer, .fork, .askHuman].contains(snap.trigger) && sink == nil {
let row = Row(.snapshot(snap), taskId: rowTask)
ctx.snapshots[snapKey(snap.n)] = row
if ![.answer, .fork, .askHuman].contains(snap.trigger) && sink == nil && own {
lastReady = nil
}
put(row)
continue
}
if ev.kind == "task.forked", let fork = str(p, "fork") {
if let row = ctx.snapshots[snapKey(int(p, "forked_at") ?? 0)] {
row.forks.append(fork)
continue
}
put(Row(.note(Note(parts: [.text("\(ev.actor) forked snapshot #\(jsString(p["forked_at"])) as "), .task(fork)], when: ev.createdAt)), taskId: rowTask))
continue
}
if ev.kind == "review.started" || ev.kind == "review.finished" || ev.kind == "review.finding" {
let r = try! decodePayload(ev.payload, as: Review.self)
ctx.reviewButtons[r.snapshotN]?.review = r
ctx.reviews[r.snapshotN] = r
ctx.snapshots[snapKey(r.snapshotN)]?.review = r
ctx.reviews[snapKey(r.snapshotN)] = r
for row in ctx.reviewRows[r.id] ?? [] {
row.review = r
}
@@ -509,10 +525,10 @@ final class TranscriptBuilder {
if ev.kind == "agent.tool_call" && sub == nil && str(p, "name") == "mcp__byre__respond_to_finding" {
let input = dict(p, "input") ?? [:]
let n = int(input, "snapshot") ?? 0
let row = Row(.findingResponse(snapshot: n, index: (int(input, "finding") ?? 0) - 1, response: str(input, "response") ?? "", reason: str(input, "reason") ?? ""), taskId: taskId)
let row = Row(.findingResponse(snapshot: n, index: (int(input, "finding") ?? 0) - 1, response: str(input, "response") ?? "", reason: str(input, "reason") ?? ""), taskId: rowTask)
row.started = ev.createdAt
ctx.tools[jsString(p["id"])] = row
if let r = ctx.reviews[n], r.state == .done {
if let r = ctx.reviews[snapKey(n)], r.state == .done {
row.review = r
ctx.reviewRows[r.id, default: []].append(row)
}
@@ -569,16 +585,16 @@ final class TranscriptBuilder {
if ev.kind == "user.message" && !bool(p, "mid_turn") {
ctx.open = true
}
if ev.kind == "turn.ended" && bool(p, "summary") && taskId != nil {
if ev.kind == "turn.ended" && bool(p, "summary") && taskId != nil && own {
row.summarySeq = ev.seq
}
if ev.kind == "task.merge_after_review" && sink == nil {
if ev.kind == "task.merge_after_review" && sink == nil && own {
lastMergeAfterReview = row.id
}
if (ev.kind == "task.ready" || ev.kind == "task.discard_suggested") && sink == nil {
if (ev.kind == "task.ready" || ev.kind == "task.discard_suggested") && sink == nil && own {
lastReady = row.id
}
if (ev.kind == "inbox.created" || ev.kind == "task.ready_cleared") && sink == nil {
if (ev.kind == "inbox.created" || ev.kind == "task.ready_cleared") && sink == nil && own {
lastReady = nil
}
put(row, keepHeld: ev.kind == "agent.thinking")
@@ -586,6 +602,10 @@ final class TranscriptBuilder {
return contextChanged
}
private func snapKey(_ n: Int) -> String {
"\(rowTask ?? "")/\(n)"
}
private func claudeTaskStatus(_ p: JSONDict) {
guard str(p, "type") == "system", let sub = ctx.subagents[jsString(p["tool_use_id"])] else {
return
@@ -619,7 +639,7 @@ final class TranscriptBuilder {
}
private func renderToolCall(_ p: JSONDict) -> Row {
let row = Row(.tool(name: toolLabel(jsString(p["name"])), brief: toolBrief(p["input"] ?? nil, worktree()), input: prettyJSON(p["input"] ?? nil)), taskId: taskId)
let row = Row(.tool(name: toolLabel(jsString(p["name"])), brief: toolBrief(p["input"] ?? nil, worktree()), input: prettyJSON(p["input"] ?? nil)), taskId: rowTask)
row.tag = subagentTag(parentOf(p))
row.owner = ctx.subagents[parentOf(p)]?.row
ctx.tools[jsString(p["id"])] = row
@@ -630,7 +650,7 @@ final class TranscriptBuilder {
let input = dict(p, "input") ?? [:]
let id = jsString(p["id"])
let type = str(input, "subagent_type") ?? "subagent"
let row = Row(.subagent(title: "\(jsString(p["name"])) \(type)", description: str(input, "description") ?? "", prompt: parseMarkdown(str(input, "prompt") ?? "")), taskId: taskId, body: true)
let row = Row(.subagent(title: "\(jsString(p["name"])) \(type)", description: str(input, "description") ?? "", prompt: parseMarkdown(str(input, "prompt") ?? "")), taskId: rowTask, body: true)
row.tag = subagentTag(parentOf(p))
row.owner = ctx.subagents[parentOf(p)]?.row
ctx.tools[id] = row
@@ -643,9 +663,9 @@ final class TranscriptBuilder {
let parts: [NotePart] = [.text("review of snapshot #\(r.snapshotN): \(outcome)")]
let fs = findings(r)
let head = fs.isEmpty
? Row(.note(Note(parts: parts, when: when, style: r.state == .failed ? .error : .plain)), taskId: taskId)
: Row(.review(note: Note(parts: parts, when: when), md: parseMarkdown(str(r.result?.value ?? [:], "summary") ?? "")), taskId: taskId)
let rows = [head] + fs.indices.map { Row(.finding(index: $0), taskId: taskId) }
? Row(.note(Note(parts: parts, when: when, style: r.state == .failed ? .error : .plain)), taskId: rowTask)
: Row(.review(note: Note(parts: parts, when: when), md: parseMarkdown(str(r.result?.value ?? [:], "summary") ?? "")), taskId: rowTask)
let rows = [head] + fs.indices.map { Row(.finding(index: $0), taskId: rowTask) }
for row in rows {
row.review = r
row.reviewOf = r.snapshotN
@@ -658,7 +678,7 @@ final class TranscriptBuilder {
let p = ev.payload.value
let when = ev.createdAt
func note(_ parts: [NotePart], _ style: NoteStyle = .plain, time: Bool = true) -> Row {
Row(.note(Note(parts: parts, when: time ? when : nil, style: style)), taskId: taskId)
Row(.note(Note(parts: parts, when: time ? when : nil, style: style)), taskId: rowTask)
}
switch ev.kind {
@@ -671,17 +691,17 @@ final class TranscriptBuilder {
if ev.actor == "byre" {
let first = text.components(separatedBy: "\n").first ?? ""
let brief = first.count > 120 ? "\(first.prefix(120))…" : first
let row = Row(.byrePrompt(brief: brief, when: when, md: parseMarkdown(text)), taskId: taskId)
let row = Row(.byrePrompt(brief: brief, when: when, md: parseMarkdown(text)), taskId: rowTask)
row.hidden = bool(p, "continuation")
return row
}
let images = (p["images"] as? [(any Sendable)?] ?? []).map { jsString($0) }
return Row(.user(actor: ev.actor, when: when, images: images, md: trimmed(text).isEmpty ? nil : parseMarkdown(text)), taskId: taskId)
return Row(.user(actor: ev.actor, when: when, images: images, md: trimmed(text).isEmpty ? nil : parseMarkdown(text)), taskId: rowTask)
case "agent.message":
let text = jsString(p["text"])
if !parentOf(p).isEmpty {
let row = Row(.subagentMessage(when: when, md: parseMarkdown(text)), taskId: taskId)
let row = Row(.subagentMessage(when: when, md: parseMarkdown(text)), taskId: rowTask)
row.tag = subagentTag(parentOf(p))
return row
}
@@ -690,17 +710,17 @@ final class TranscriptBuilder {
return nil
}
ctx.quiet = false
return Row(.agent(md: parseMarkdown(text)), taskId: taskId)
return Row(.agent(md: parseMarkdown(text)), taskId: rowTask)
case "agent.thinking":
let row = Row(.thinking(md: parseMarkdown(jsString(p["text"]))), taskId: taskId)
let row = Row(.thinking(md: parseMarkdown(jsString(p["text"]))), taskId: rowTask)
row.tag = subagentTag(parentOf(p))
return row
case "agent.tool_result":
let body = resultText(p["content"] ?? nil)
guard let target = ctx.tools[jsString(p["tool_use_id"])] else {
return Row(.orphan(body), taskId: taskId)
return Row(.orphan(body), taskId: rowTask)
}
target.result = body
if case .findingResponse = target.kind, bool(p, "is_error") {
@@ -713,7 +733,7 @@ final class TranscriptBuilder {
if bool(p, "handoff") {
return note([.text("handoff written; continuing in a new session with a fresh context")])
}
return Row(.turnEnd(error: bool(p, "is_error") ? "turn ended: \(jsString(p["subtype"]).replacingOccurrences(of: "_", with: " "))" : nil), taskId: taskId)
return Row(.turnEnd(error: bool(p, "is_error") ? "turn ended: \(jsString(p["subtype"]).replacingOccurrences(of: "_", with: " "))" : nil), taskId: rowTask)
case "session.created":
let model = str(p, "model") ?? ""
@@ -729,7 +749,7 @@ final class TranscriptBuilder {
return note([.text("session \(jsString(p["state"])): \(error)")], .error, time: false)
case "inbox.created":
let row = Row(.question, taskId: taskId)
let row = Row(.question, taskId: rowTask)
let item = try! decodePayload(ev.payload, as: InboxItem.self)
row.question = item
ctx.questions[item.id] = row
@@ -744,8 +764,11 @@ final class TranscriptBuilder {
return note([.text("\(jsString(p["error"])) (\(jsString(p["vendor"])) \(limit["rateLimitType"].map { jsString($0) } ?? "") limit resets \(resets))")], .error)
case "task.created":
if let from = str(p, "forked_from"), let task = rowTask, task != taskId {
return note([.text("\(ev.actor) forked "), .task(task), .text(" here from "), .task(from), .text(" at snapshot #\(jsString(p["forked_at"]))")])
}
if let from = str(p, "forked_from") {
return note([.text("\(ev.actor) forked this task from "), .task(from), .text(" at snapshot #\(jsString(p["forked_at"])); its agent continues that task's conversation as it stood there")])
return note([.text("\(ev.actor) forked this task here from "), .task(from), .text(" at snapshot #\(jsString(p["forked_at"])); everything above happened in that task, and its agent continues that conversation")])
}
if let parentTask = str(p, "parent_task_id") {
return note([.text("subtask of "), .task(parentTask), .text(", created by its agent")])
@@ -773,7 +796,7 @@ final class TranscriptBuilder {
case "task.merge_after_review":
var n = Note(parts: [.text("\(ev.actor) asked to merge once the review of snapshot #\(jsString(p["snapshot_n"])) finishes with no findings")], when: when)
n.cancelMergeAfterReview = jsString(p["id"])
return Row(.note(n), taskId: taskId)
return Row(.note(n), taskId: rowTask)
case "task.merge_after_review_cancelled":
return note([.text("\(ev.actor) cancelled the merge after review")])
@@ -799,10 +822,10 @@ final class TranscriptBuilder {
return note([.text("\(ev.actor) set the task summary")])
case "task.ready":
return Row(.ready(when: when, md: parseMarkdown(jsString(p["overview"])), discard: false), taskId: taskId)
return Row(.ready(when: when, md: parseMarkdown(jsString(p["overview"])), discard: false), taskId: rowTask)
case "task.discard_suggested":
return Row(.ready(when: when, md: parseMarkdown(jsString(p["reason"])), discard: true), taskId: taskId)
return Row(.ready(when: when, md: parseMarkdown(jsString(p["reason"])), discard: true), taskId: rowTask)
case "task.ready_cleared":
return note([.text(ev.actor == "agent" ? "the agent withdrew its ready call" : "\(ev.actor) dismissed the ready call")])
@@ -833,7 +856,7 @@ func findingCount(_ r: Review) -> Int {
r.state == .done ? findings(r).indices.filter { findingOpen(r, $0) }.count : 0
}
func latestReview(_ events: [ByreEvent]) -> Review? {
func latestReview(_ events: ArraySlice<ByreEvent>) -> Review? {
var best: Review?
for ev in events where isReviewEvent(ev) {
let r = try! decodePayload(ev.payload, as: Review.self)