Merge nucleic/keen-river-shrew-4cdk into dev

This commit is contained in:
2026-07-19 03:40:03 -07:00
parent ccdb581e8c
commit f1483684c6
3 changed files with 110 additions and 9 deletions
@@ -378,6 +378,27 @@ final class RemoteStore: ObservableObject {
@Published private(set) var openEvents: [AgentEvent] = [] @Published private(set) var openEvents: [AgentEvent] = []
@Published private(set) var openApprovals: [ApprovalRequest] = [] @Published private(set) var openApprovals: [ApprovalRequest] = []
/// Stall ids the user acted on locally (Kill / Keep-waiting) before the host's authoritative
/// resolution note has arrived — an optimistic set so the alert row retires its buttons
/// immediately. The host's resolution note (carried in `openEvents`) is the durable signal;
/// `resolvedStalls` unions the two. Mirrors the Mac's `AppStore.resolvedStalls`.
@Published private var locallyResolvedStalls: Set<String> = []
/// Stall ids whose "host command looks hung" alert has been closed out — resolved locally
/// (optimistic) or by a resolution note (`ProcessStallNote.resolution != nil`) in the open
/// transcript. The transcript's alert row reads this to retire its Kill / Keep-waiting buttons
/// once the alert is done. Scans only the open transcript, and only a (rare) alert row reads it.
var resolvedStalls: Set<String> {
var ids = locallyResolvedStalls
for event in openEvents {
if case .note(let note) = event.kind, let stall = note.processStall,
stall.resolution != nil {
ids.insert(stall.id)
}
}
return ids
}
/// The open session is blocked on an approval whose details haven't arrived yet — the state /// The open session is blocked on an approval whose details haven't arrived yet — the state
/// right after opening from a Live Activity or notification while the channel is still /// right after opening from a Live Activity or notification while the channel is still
/// (re)connecting. The summary says `.awaitingApproval`, but `openApprovals` (which is filled /// (re)connecting. The summary says `.awaitingApproval`, but `openApprovals` (which is filled
@@ -660,7 +681,7 @@ final class RemoteStore: ObservableObject {
case .sendInput, .startChat, .captureTodo, .dispatchTodo, .setTodoStatus, .deleteTodo, case .sendInput, .startChat, .captureTodo, .dispatchTodo, .setTodoStatus, .deleteTodo,
.renameSession, .setFavorite, .setArchived, .deleteSession, .discard, .integrate, .renameSession, .setFavorite, .setArchived, .deleteSession, .discard, .integrate,
.interrupt, .cancelQueuedMessage, .setSessionModel, .setSessionEffort, .setSessionAuto, .interrupt, .cancelQueuedMessage, .setSessionModel, .setSessionEffort, .setSessionAuto,
.setSessionAutoShip, .setSessionShipBranch, .approvalRespond: .setSessionAutoShip, .setSessionShipBranch, .approvalRespond, .resolveProcessStall:
return true return true
default: default:
return false return false
@@ -2399,6 +2420,14 @@ final class RemoteStore: ObservableObject {
/// Throw away the session's branch/worktree without landing it (the Mac's Discard…). /// Throw away the session's branch/worktree without landing it (the Mac's Discard…).
func discard(_ id: SessionID) { send(.discard(id)) } func discard(_ id: SessionID) { send(.discard(id)) }
func interrupt(_ id: SessionID) { send(.interrupt(id)) } func interrupt(_ id: SessionID) { send(.interrupt(id)) }
/// Resolve a live "host command looks hung" alert — the transcript's Kill (`kill: true`, tears
/// down the command's process tree) / Keep-waiting (`kill: false`, dismiss) buttons. Optimistically
/// marks the alert resolved so its buttons retire at once; the host then emits the authoritative
/// resolution note. Mirrors the Mac's `AppStore.resolveProcessStall`.
func resolveProcessStall(_ stallID: String, kill: Bool, inSession id: SessionID) {
locallyResolvedStalls.insert(stallID)
send(.resolveProcessStall(id, stallID: stallID, kill: kill))
}
/// Cancel one queued (not-yet-sent) follow-up by id — the phone's per-message ✕. /// Cancel one queued (not-yet-sent) follow-up by id — the phone's per-message ✕.
func cancelQueuedMessage(_ id: SessionID, _ messageID: UUID) { send(.cancelQueuedMessage(id, messageID)) } func cancelQueuedMessage(_ id: SessionID, _ messageID: UUID) { send(.cancelQueuedMessage(id, messageID)) }
@@ -2525,7 +2554,8 @@ final class RemoteStore: ObservableObject {
.integrate(let id, _), .renameSession(let id, _), .setFavorite(let id, _), .integrate(let id, _), .renameSession(let id, _), .setFavorite(let id, _),
.setArchived(let id, _), .setSessionModel(let id, _), .setSessionEffort(let id, _), .setArchived(let id, _), .setSessionModel(let id, _), .setSessionEffort(let id, _),
.setSessionAuto(let id, _), .setSessionAutoShip(let id, _), .setSessionShipBranch(let id, _), .setSessionAuto(let id, _), .setSessionAutoShip(let id, _), .setSessionShipBranch(let id, _),
.sendInput(let id, _), .cancelQueuedMessage(let id, _), .fetchDiff(let id): .sendInput(let id, _), .cancelQueuedMessage(let id, _), .fetchDiff(let id),
.resolveProcessStall(let id, _, _):
connection(owningSession: id)?.send(msg) connection(owningSession: id)?.send(msg)
// Project-owning intents → the Mac that has this project. // Project-owning intents → the Mac that has this project.
@@ -2758,6 +2788,15 @@ final class RemoteStore: ObservableObject {
demoUpdateSession(id) { $0.demoCopy(autoShip: autoShip) } demoUpdateSession(id) { $0.demoCopy(autoShip: autoShip) }
case .setSessionShipBranch(let id, let branch): case .setSessionShipBranch(let id, let branch):
demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) } demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) }
case .resolveProcessStall(let id, let stallID, let kill):
// No real command runs in demo; echo the resolution note so the alert row retires just
// as it would against a live host (its `resolution` unions into `resolvedStalls`).
demoAppend(id, .note(NoteEvent(
text: kill ? "Killed the stalled command." : "Dismissed the stall alert.",
icon: kill ? "xmark.octagon" : "clock",
processStall: ProcessStallNote(
id: stallID, label: "", pidCount: 1, idleSeconds: 0,
resolution: kill ? "killed" : "dismissed"))))
case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe, case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe,
.ping, .cancelQueuedMessage, .fetchDiff, .fetchTranscript, .listPeers, .ping, .cancelQueuedMessage, .fetchDiff, .fetchTranscript, .listPeers,
.addressUpdate, .meshRoster, .addressUpdate, .meshRoster,
@@ -36,8 +36,12 @@ struct TranscriptItem: Identifiable, Equatable {
case error(message: String) case error(message: String)
/// A passthrough note. `lock` carries the structured lock detail when this is a file-lock /// A passthrough note. `lock` carries the structured lock detail when this is a file-lock
/// lifecycle moment, so the projection can fold it onto the edit card it brackets; `nil` /// lifecycle moment, so the projection can fold it onto the edit card it brackets; `nil`
/// for every non-lock note (and lock notes with no paths). /// for every non-lock note (and lock notes with no paths). `processStall` carries the
case note(text: String, icon: String?, lockEvent: Bool, lock: NoteLock? = nil) /// "host command looks hung" alert payload when this note is one (or its resolution), so the
/// row can render the amber Kill / Keep-waiting alert (control parity with the Mac); `nil`
/// for every other note.
case note(text: String, icon: String?, lockEvent: Bool, lock: NoteLock? = nil,
processStall: ProcessStallNote? = nil)
case raw(type: String, body: String) case raw(type: String, body: String)
} }
@@ -253,7 +257,7 @@ enum TranscriptProjection {
var locksByCall: [String: [NoteLock]] = [:] var locksByCall: [String: [NoteLock]] = [:]
var folded = Set<Int>() var folded = Set<Int>()
for (i, item) in flat.enumerated() { for (i, item) in flat.enumerated() {
guard case .note(_, _, true, let noteLock) = item.kind, guard case .note(_, _, true, let noteLock, _) = item.kind,
let lock = noteLock, !lock.paths.isEmpty else { continue } let lock = noteLock, !lock.paths.isEmpty else { continue }
// Route each path to the nearest preceding edit card that touches it, so a multi-file // Route each path to the nearest preceding edit card that touches it, so a multi-file
// note brackets each file's own card. Fold only when *every* path lands on a card; a // note brackets each file's own card. Fold only when *every* path lands on a card; a
@@ -557,7 +561,8 @@ enum TranscriptProjection {
if note.lockEvent && !showLockEvents { break } if note.lockEvent && !showLockEvents { break }
items.append(.init(id: "note-\(event.seq)", seq: event.seq, items.append(.init(id: "note-\(event.seq)", seq: event.seq,
kind: .note(text: note.text, icon: note.icon, kind: .note(text: note.text, icon: note.icon,
lockEvent: note.lockEvent, lock: note.lock))) lockEvent: note.lockEvent, lock: note.lock,
processStall: note.processStall)))
case .raw(let raw): case .raw(let raw):
guard showRaw else { break } guard showRaw else { break }
items.append(.init(id: "raw-\(event.seq)", seq: event.seq, items.append(.init(id: "raw-\(event.seq)", seq: event.seq,
@@ -58,9 +58,16 @@ struct TranscriptRow: View {
case .error(let message): case .error(let message):
Label(message, systemImage: "xmark.octagon.fill") Label(message, systemImage: "xmark.octagon.fill")
.font(.caption).foregroundStyle(Palette.danger) .font(.caption).foregroundStyle(Palette.danger)
case .note(let text, let icon, _, _): case .note(let text, let icon, _, _, let processStall):
Label(text, systemImage: icon ?? "arrow.triangle.branch") // A live "host command looks hung" alert reads as a distinct amber row with Kill /
.font(.caption).foregroundStyle(.secondary) // Keep-waiting buttons (control parity with the Mac's `ProcessStallRow`); once resolved
// — or for any non-stall note — it falls back to the plain secondary label.
if let stall = processStall, stall.resolution == nil {
ProcessStallRow(text: text, stall: stall)
} else {
Label(text, systemImage: icon ?? "arrow.triangle.branch")
.font(.caption).foregroundStyle(.secondary)
}
case .raw(let type, let body): case .raw(let type, let body):
Text("[\(type)] \(body)").font(.caption2.monospaced()).foregroundStyle(.secondary).lineLimit(2) Text("[\(type)] \(body)").font(.caption2.monospaced()).foregroundStyle(.secondary).lineLimit(2)
} }
@@ -100,6 +107,56 @@ struct TranscriptRow: View {
} }
} }
/// The in-chat rendering for a live "host command looks hung" alert — the mobile echo of the Mac's
/// `ProcessStallRow`, for control parity (UX_IOS §5). The harness raises it when a running
/// `host_exec` command goes silent with near-zero CPU (see `ProcessStallMonitor`). It reads as an
/// amber warning row with **Kill** (tears down the command's whole process tree) and **Keep
/// waiting** (dismiss the alert; the command runs on). Once resolved — killed, dismissed, or the
/// command ended on its own — the buttons give way to a muted "resolved" note. Buttons are hidden
/// when the device lacks control scope or the host can't resolve stalls remotely (older host),
/// degrading to a plain warning line.
struct ProcessStallRow: View {
@EnvironmentObject var store: RemoteStore
let text: String
let stall: ProcessStallNote
private var resolved: Bool { store.resolvedStalls.contains(stall.id) }
private var canResolve: Bool {
store.canControl && store.capabilities.canResolveProcessStall && store.openSessionID != nil
}
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.caption).foregroundStyle(Palette.attention).frame(width: 16)
Text(text)
.font(.callout).foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true).textSelection(.enabled)
if resolved {
Text("resolved").font(.caption).foregroundStyle(.secondary)
} else if canResolve, let sessionID = store.openSessionID {
Button {
store.resolveProcessStall(stall.id, kill: true, inSession: sessionID)
} label: {
Label(
stall.pidCount > 1 ? "Kill (\(stall.pidCount) processes)" : "Kill",
systemImage: "xmark.octagon")
.font(.caption.weight(.semibold))
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.tint(Palette.danger)
Button("Keep waiting") {
store.resolveProcessStall(stall.id, kill: false, inSession: sessionID)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
Spacer(minLength: 0)
}
}
}
/// Render an assistant/user message with the full Markdown renderer the Mac transcript uses /// Render an assistant/user message with the full Markdown renderer the Mac transcript uses
/// (fenced code blocks, headings, lists, tables, and inline emphasis/links/`code`), preserving /// (fenced code blocks, headings, lists, tables, and inline emphasis/links/`code`), preserving
/// the line breaks of multi-paragraph replies. Falls back to plain text if parsing fails. /// the line breaks of multi-paragraph replies. Falls back to plain text if parsing fails.