mac app active tool animation

This commit is contained in:
Ian Gulliver
2026-10-17 08:57:26 -07:00
parent b1eb595af1
commit ebf1561c4a
4 changed files with 67 additions and 10 deletions
+1 -1
View File
@@ -97,7 +97,7 @@ Closing the window leaves the app running with its store and streams (`applicati
Each view does what its TypeScript counterpart does, with the same text, refresh triggers and empty states. Each view does what its TypeScript counterpart does, with the same text, refresh triggers and empty states.
- **Transcript and Lead.** `TranscriptBuilder` ports `appendEvents` and `renderEvent` from `task.ts` as a pure function from events to a list of rows: user and agent messages, consecutive tool calls folded into one group with its summary ("Read file ×3"), subagents nested with their status, thinking, the byre prompt collapsed, held turns, `NO_REPLY` turns folded away, handoffs, snapshot rows with changes, fork and review, and notes for each event kind the web client draws. It is incremental: a new batch extends the existing rows. The view is a `ScrollView` with a `LazyVStack`, stuck to the bottom unless the user has scrolled more than 80 points up, with pending sends shown at the end until the server echoes them. A turn summary counts as read when its row is on screen and the app is active (`markSummaryRead`). The head carries Compact (with its context-usage fill), Sync from main, Merge and Discard, with the same enabling rules and tooltips. - **Transcript and Lead.** `TranscriptBuilder` ports `appendEvents` and `renderEvent` from `task.ts` as a pure function from events to a list of rows: user and agent messages, consecutive tool calls folded into one group with its summary ("Read file ×3"), subagents nested with their status, pending tool calls and running subagents (background ones included after the turn ends) outlined in the accent colour with a 0.6s fade, thinking, the byre prompt collapsed, held turns, `NO_REPLY` turns folded away, handoffs, snapshot rows with changes, fork and review, and notes for each event kind the web client draws. It is incremental: a new batch extends the existing rows. The view is a `ScrollView` with a `LazyVStack`, stuck to the bottom unless the user has scrolled more than 80 points up, with pending sends shown at the end until the server echoes them. A turn summary counts as read when its row is on screen and the app is active (`markSummaryRead`). The head carries Compact (with its context-usage fill), Sync from main, Merge and Discard, with the same enabling rules and tooltips.
- **Composer.** A `TextEditor`, with paste and drop handled as in "Gaps in SwiftUI". Text, the model menu (filtered by vendor as in `modelSelect`), Interrupt and Send. Enter sends, Shift-Enter adds a newline, and Enter during IME composition does neither. Pasted or dropped PNG, JPEG, GIF and WebP images up to the web client's limit become thumbnails with ×, uploaded to `POST /api/blobs` on send. A failed send puts the text and images back and shows the error. - **Composer.** A `TextEditor`, with paste and drop handled as in "Gaps in SwiftUI". Text, the model menu (filtered by vendor as in `modelSelect`), Interrupt and Send. Enter sends, Shift-Enter adds a newline, and Enter during IME composition does neither. Pasted or dropped PNG, JPEG, GIF and WebP images up to the web client's limit become thumbnails with ×, uploaded to `POST /api/blobs` on send. A failed send puts the text and images back and shows the error.
- **Inbox.** The selected task's oldest open item, rendered from its Markdown context with each choice's marker replaced by a clickable block (pick-one, sends at once) or checkbox (pick-any, with Submit), the "recommended" tag, image-choice grids, attachments, Let the agent decide, Other… and, for errors, Reply… and Dismiss. Arrow keys move between choices. A 409 shows its error as in the browser. The Changes summary line sits above it. - **Inbox.** The selected task's oldest open item, rendered from its Markdown context with each choice's marker replaced by a clickable block (pick-one, sends at once) or checkbox (pick-any, with Submit), the "recommended" tag, image-choice grids, attachments, Let the agent decide, Other… and, for errors, Reply… and Dismiss. Arrow keys move between choices. A 409 shows its error as in the browser. The Changes summary line sits above it.
- **Changes, Files, Reviews, Workflow, Project, Status, Settings.** Ports of their files, including the pinned-snapshot stepper, open-diff memory, per-task place in Files, finding diffs with Fix and Ignore, nested subtasks, TODO Start and Discuss, rate-limit pace bars and lead notes. The Workflow tab draws its stage graph natively from the `WorkflowRun` (stages as boxes coloured by state, `after` as edges, laid out top to bottom by depth) with the layout and drawing code from "Diagrams", fed from the run's data rather than Mermaid text. - **Changes, Files, Reviews, Workflow, Project, Status, Settings.** Ports of their files, including the pinned-snapshot stepper, open-diff memory, per-task place in Files, finding diffs with Fix and Ignore, nested subtasks, TODO Start and Discuss, rate-limit pace bars and lead notes. The Workflow tab draws its stage graph natively from the `WorkflowRun` (stages as boxes coloured by state, `after` as edges, laid out top to bottom by depth) with the layout and drawing code from "Diagrams", fed from the run's data rather than Mermaid text.
+17 -4
View File
@@ -92,6 +92,14 @@ final class ChatModel {
builder.open && session?.state == .running builder.open && session?.state == .running
} }
var subagents: Bool {
(session?.subagents ?? 0) > 0
}
func working(_ row: Row) -> Bool {
row.pending && (running || subagents && row.inActive) || row.active && (running || subagents)
}
var hasOlder: Bool { var hasOlder: Bool {
kind == .lead && store.leadHasOlder() kind == .lead && store.leadHasOlder()
} }
@@ -609,6 +617,7 @@ struct DetailsBox<Summary: View, Content: View>: View {
@ViewBuilder let content: Content @ViewBuilder let content: Content
@Environment(\.paneAccent) private var accent @Environment(\.paneAccent) private var accent
@ViewState private var open = false @ViewState private var open = false
@ViewState private var shown = false
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
@@ -637,9 +646,13 @@ struct DetailsBox<Summary: View, Content: View>: View {
.background(boxed ? (nested ? Palette.raised : Palette.surface) : .clear, in: RoundedRectangle(cornerRadius: 8)) .background(boxed ? (nested ? Palette.raised : Palette.surface) : .clear, in: RoundedRectangle(cornerRadius: 8))
.overlay { .overlay {
if boxed { if boxed {
RoundedRectangle(cornerRadius: 8).stroke(working ? accent : Palette.border) RoundedRectangle(cornerRadius: 8).stroke(working && shown ? accent : Palette.border)
.animation(.easeInOut(duration: 0.6), value: working && shown)
} }
} }
.onAppear {
shown = true
}
} }
} }
@@ -747,7 +760,7 @@ struct RowView: View {
} }
case .tool(let name, let brief, let input): case .tool(let name, let brief, let input):
DetailsBox(nested: nested, working: model.running && row.pending) { DetailsBox(nested: nested, working: model.working(row)) {
SubagentTag(show: row.tag) SubagentTag(show: row.tag)
Text(styled([(name, 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)])) Text(styled([(name, 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)]))
} content: { } content: {
@@ -759,7 +772,7 @@ struct RowView: View {
} }
case .subagent(let title, let description, let prompt): case .subagent(let title, let description, let prompt):
DetailsBox(nested: nested, working: model.running && row.pending) { DetailsBox(nested: nested, working: model.working(row)) {
SubagentTag(show: row.tag) SubagentTag(show: row.tag)
Text(styled([(title, 600, nil), (" ", 400, nil), (description, 400, Palette.muted)])) Text(styled([(title, 600, nil), (" ", 400, nil), (description, 400, Palette.muted)]))
Spacer(minLength: 12) Spacer(minLength: 12)
@@ -778,7 +791,7 @@ struct RowView: View {
} }
case .group: case .group:
DetailsBox(nested: nested, working: model.running && row.pending) { DetailsBox(nested: nested, working: model.working(row)) {
SubagentTag(show: row.tag) SubagentTag(show: row.tag)
RunSummary(calls: row.calls) RunSummary(calls: row.calls)
} content: { } content: {
+2 -2
View File
@@ -18,7 +18,7 @@ struct TaskState {
} }
private let workingTitles: [ByreTask.StatePayload: String] = [ private let workingTitles: [ByreTask.StatePayload: String] = [
.running: "The agent is mid-turn", .running: "The agent is mid-turn or its subagents are running",
.reviewPending: "A review of the turn's work is about to start", .reviewPending: "A review of the turn's work is about to start",
.reviewing: "A review is running", .reviewing: "A review is running",
.discarding: "Discarding", .discarding: "Discarding",
@@ -488,7 +488,7 @@ final class Store {
try await self.loadTasks(projectId) try await self.loadTasks(projectId)
} }
} }
case "session.created", "session.state", "session.model": case "session.created", "session.state", "session.subagents", "session.model":
if ev.owner != nil { if ev.owner != nil {
spawn("load lead sessions") { spawn("load lead sessions") {
try await self.loadLeadSessions() try await self.loadLeadSessions()
+47 -3
View File
@@ -75,6 +75,8 @@ final class Row: Identifiable {
var unfinished = false var unfinished = false
var status = "starting…" var status = "starting…"
var statusState = SubagentState.running var statusState = SubagentState.running
var active = false
weak var owner: Row?
var calls: [Call] = [] var calls: [Call] = []
var summary: Note? var summary: Note?
var review: Review? var review: Review?
@@ -94,6 +96,28 @@ final class Row: Identifiable {
} }
} }
var inActive: Bool {
var o = owner
while let r = o {
if r.active {
return true
}
o = r.owner
}
return false
}
func within(_ ancestor: Row) -> Bool {
var o = owner
while let r = o {
if r === ancestor {
return true
}
o = r.owner
}
return false
}
init(_ kind: RowKind, taskId: String?, body: Bool = false) { init(_ kind: RowKind, taskId: String?, body: Bool = false) {
rowIds += 1 rowIds += 1
id = rowIds id = rowIds
@@ -275,6 +299,7 @@ final class TranscriptBuilder {
} }
if run.group == nil { if run.group == nil {
let group = Row(.group, taskId: taskId, body: true) let group = Row(.group, taskId: taskId, body: true)
group.owner = ctx.subagents[parent]?.row
for list in [run.container, sink ?? root] { for list in [run.container, sink ?? root] {
if let i = list.rows.firstIndex(where: { $0 === run.first }) { if let i = list.rows.firstIndex(where: { $0 === run.first }) {
list.rows[i] = group list.rows[i] = group
@@ -368,6 +393,14 @@ final class TranscriptBuilder {
claudeTaskStatus(p) claudeTaskStatus(p)
continue continue
} }
if ev.kind == "session.subagents" {
if int(p, "subagents") == 0 {
for sub in ctx.subagents.values where sub.row.active {
endSubagent(sub.row, .error, "ended with its process")
}
}
continue
}
if sink == nil && ((ev.kind == "agent.message" && parent.isEmpty) || ev.kind == "turn.ended" || ev.kind == "user.message") { if sink == nil && ((ev.kind == "agent.message" && parent.isEmpty) || ev.kind == "turn.ended" || ev.kind == "user.message") {
dropStream() dropStream()
} }
@@ -475,20 +508,30 @@ final class TranscriptBuilder {
} }
switch str(p, "subtype") { switch str(p, "subtype") {
case "task_started": case "task_started":
sub.row.active = true
sub.row.status = bool(p, "is_backgrounded") ? "running in the background" : "running" sub.row.status = bool(p, "is_backgrounded") ? "running in the background" : "running"
case "task_progress": case "task_progress":
sub.row.status = str(p, "description") ?? "running" sub.row.status = str(p, "description") ?? "running"
case "task_notification": case "task_notification":
sub.row.statusState = str(p, "status") == "completed" ? .done : .error endSubagent(sub.row, str(p, "status") == "completed" ? .done : .error, jsString(p["status"]))
sub.row.status = jsString(p["status"])
default: default:
break break
} }
} }
private func endSubagent(_ row: Row, _ state: SubagentState, _ status: String) {
row.active = false
row.statusState = state
row.status = status
for tool in ctx.tools.values where tool.result == nil && tool.within(row) {
tool.unfinished = true
}
}
private func renderToolCall(_ p: JSONDict) -> Row { 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: taskId)
row.tag = subagentTag(parentOf(p)) row.tag = subagentTag(parentOf(p))
row.owner = ctx.subagents[parentOf(p)]?.row
ctx.tools[jsString(p["id"])] = row ctx.tools[jsString(p["id"])] = row
return row return row
} }
@@ -499,6 +542,7 @@ final class TranscriptBuilder {
let type = str(input, "subagent_type") ?? "subagent" 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: taskId, body: true)
row.tag = subagentTag(parentOf(p)) row.tag = subagentTag(parentOf(p))
row.owner = ctx.subagents[parentOf(p)]?.row
ctx.tools[id] = row ctx.tools[id] = row
ctx.subagents[id] = Subagent(row: row) ctx.subagents[id] = Subagent(row: row)
return row return row
@@ -553,7 +597,7 @@ final class TranscriptBuilder {
return nil return nil
case "turn.ended": case "turn.ended":
for row in ctx.tools.values where row.result == nil { for row in ctx.tools.values where row.result == nil && !(row.active || row.inActive) {
row.unfinished = true row.unfinished = true
} }
if bool(p, "handoff") { if bool(p, "handoff") {