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
+17 -4
View File
@@ -92,6 +92,14 @@ final class ChatModel {
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 {
kind == .lead && store.leadHasOlder()
}
@@ -609,6 +617,7 @@ struct DetailsBox<Summary: View, Content: View>: View {
@ViewBuilder let content: Content
@Environment(\.paneAccent) private var accent
@ViewState private var open = false
@ViewState private var shown = false
var body: some View {
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))
.overlay {
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):
DetailsBox(nested: nested, working: model.running && row.pending) {
DetailsBox(nested: nested, working: model.working(row)) {
SubagentTag(show: row.tag)
Text(styled([(name, 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)]))
} content: {
@@ -759,7 +772,7 @@ struct RowView: View {
}
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)
Text(styled([(title, 600, nil), (" ", 400, nil), (description, 400, Palette.muted)]))
Spacer(minLength: 12)
@@ -778,7 +791,7 @@ struct RowView: View {
}
case .group:
DetailsBox(nested: nested, working: model.running && row.pending) {
DetailsBox(nested: nested, working: model.working(row)) {
SubagentTag(show: row.tag)
RunSummary(calls: row.calls)
} content: {
+2 -2
View File
@@ -18,7 +18,7 @@ struct TaskState {
}
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",
.reviewing: "A review is running",
.discarding: "Discarding",
@@ -488,7 +488,7 @@ final class Store {
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 {
spawn("load lead sessions") {
try await self.loadLeadSessions()
+47 -3
View File
@@ -75,6 +75,8 @@ final class Row: Identifiable {
var unfinished = false
var status = "starting…"
var statusState = SubagentState.running
var active = false
weak var owner: Row?
var calls: [Call] = []
var summary: Note?
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) {
rowIds += 1
id = rowIds
@@ -275,6 +299,7 @@ final class TranscriptBuilder {
}
if run.group == nil {
let group = Row(.group, taskId: taskId, 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 }) {
list.rows[i] = group
@@ -368,6 +393,14 @@ final class TranscriptBuilder {
claudeTaskStatus(p)
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") {
dropStream()
}
@@ -475,20 +508,30 @@ final class TranscriptBuilder {
}
switch str(p, "subtype") {
case "task_started":
sub.row.active = true
sub.row.status = bool(p, "is_backgrounded") ? "running in the background" : "running"
case "task_progress":
sub.row.status = str(p, "description") ?? "running"
case "task_notification":
sub.row.statusState = str(p, "status") == "completed" ? .done : .error
sub.row.status = jsString(p["status"])
endSubagent(sub.row, str(p, "status") == "completed" ? .done : .error, jsString(p["status"]))
default:
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 {
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.owner = ctx.subagents[parentOf(p)]?.row
ctx.tools[jsString(p["id"])] = row
return row
}
@@ -499,6 +542,7 @@ final class TranscriptBuilder {
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)
row.tag = subagentTag(parentOf(p))
row.owner = ctx.subagents[parentOf(p)]?.row
ctx.tools[id] = row
ctx.subagents[id] = Subagent(row: row)
return row
@@ -553,7 +597,7 @@ final class TranscriptBuilder {
return nil
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
}
if bool(p, "handoff") {