Add run_on_client: agents run commands with files on the user's Macs

This commit is contained in:
Ian Gulliver
2026-10-17 14:41:04 -07:00
parent 14975d2ab8
commit fbc0699fdf
16 changed files with 928 additions and 249 deletions
+60 -5
View File
@@ -192,6 +192,21 @@ private final class CardModel {
}
}
func run() {
if busy {
return
}
busy = true
Task {
do {
try await runOnThisMac(store.api, item)
} catch {
overlays.showAlert(errorText(error))
}
busy = false
}
}
func submitOther() {
if typed.isEmpty && !hasImages {
return
@@ -289,10 +304,29 @@ private struct CardBody: View {
}
.textSelection(.enabled)
.padding(.vertical, 10)
case .clientRun:
VStack(alignment: .leading, spacing: 10) {
MarkdownView(blocks: parseMarkdown(jsString(p["why"] ?? "")), ctx: MDContext(taskId: item.taskId))
MarkdownView(blocks: runDetails(p), ctx: MDContext(taskId: item.taskId))
}
.padding(.vertical, 10)
}
}
}
private func runDetails(_ p: JSONDict) -> [MDBlock] {
let paths = (p["paths"] as? [(any Sendable)?] ?? []).compactMap { $0 as? String }
let bytes = (p["bytes"] as? Int) ?? Int((p["bytes"] as? Double) ?? 0)
var files: [MDInline] = [.text("No files.")]
if !paths.isEmpty {
files = [.text("Files (\(ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file))): ")]
for (i, path) in paths.enumerated() {
files += i == 0 ? [.code(path)] : [.text(", "), .code(path)]
}
}
return [.code(jsString(p["command"] ?? "")), .paragraph(files)]
}
private func attachments(_ v: (any Sendable)?) -> [Attachment] {
guard let v else {
return []
@@ -451,6 +485,19 @@ private struct CardActions: View {
}
.disabled(model.busy)
}
} else if model.item.kind == .clientRun {
HStack(spacing: 8) {
Button("Dismiss") {
model.submit(InboxResponseCreate(itemId: model.item.id, _type: .dismiss))
}
.buttonStyle(WebButtonStyle())
Spacer()
Button("Run on this Mac") {
model.run()
}
.buttonStyle(WebButtonStyle(primary: true))
}
.disabled(model.busy)
} else {
HStack(alignment: .top, spacing: 8) {
TextEntry(model: model, label: "Reply…", placeholder: "Reply to the agent", shared: false, focus: focus) {
@@ -596,11 +643,19 @@ private struct Thread: View {
if !item.responses.isEmpty {
VStack(alignment: .leading, spacing: 2) {
ForEach(item.responses, id: \.id) { r in
let images = r.images.isEmpty ? "" : " (\(r.images.count == 1 ? "1 image" : "\(r.images.count) images"))"
let rest = " \(r._type.rawValue.replacingOccurrences(of: "_", with: " "))\(r.options.map { ": \($0.joined(separator: "; "))" } ?? "")\(r.text.map { " — \($0)" } ?? "")\(images)"
Text("\(Text(r.user).font(Fonts.font(11, weight: 700)))\(rest)")
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
if r._type == .run {
Text("\(Text(r.user).font(Fonts.font(11, weight: 700))) ran it on \(r.client ?? ""): exit \(r.exitCode ?? -1)")
.textSelection(.enabled)
if let out = r.text, !trimmed(out).isEmpty {
MarkdownView(blocks: [.details(summary: [.text("Output")], body: [.code(out)])], ctx: MDContext(taskId: item.taskId))
}
} else {
let images = r.images.isEmpty ? "" : " (\(r.images.count == 1 ? "1 image" : "\(r.images.count) images"))"
let rest = " \(r._type.rawValue.replacingOccurrences(of: "_", with: " "))\(r.options.map { ": \($0.joined(separator: "; "))" } ?? "")\(r.text.map { " — \($0)" } ?? "")\(images)"
Text("\(Text(r.user).font(Fonts.font(11, weight: 700)))\(rest)")
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.font(Fonts.font(11))