mac app active tool animation
This commit is contained in:
+1
-1
@@ -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.
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
Reference in New Issue
Block a user