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 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
/// 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
@@ -660,7 +681,7 @@ final class RemoteStore: ObservableObject {
case .sendInput, .startChat, .captureTodo, .dispatchTodo, .setTodoStatus, .deleteTodo,
.renameSession, .setFavorite, .setArchived, .deleteSession, .discard, .integrate,
.interrupt, .cancelQueuedMessage, .setSessionModel, .setSessionEffort, .setSessionAuto,
.setSessionAutoShip, .setSessionShipBranch, .approvalRespond:
.setSessionAutoShip, .setSessionShipBranch, .approvalRespond, .resolveProcessStall:
return true
default:
return false
@@ -2399,6 +2420,14 @@ final class RemoteStore: ObservableObject {
/// Throw away the session's branch/worktree without landing it (the Mac's Discard…).
func discard(_ id: SessionID) { send(.discard(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 ✕.
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, _),
.setArchived(let id, _), .setSessionModel(let id, _), .setSessionEffort(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)
// Project-owning intents → the Mac that has this project.
@@ -2758,6 +2788,15 @@ final class RemoteStore: ObservableObject {
demoUpdateSession(id) { $0.demoCopy(autoShip: autoShip) }
case .setSessionShipBranch(let id, let 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,
.ping, .cancelQueuedMessage, .fetchDiff, .fetchTranscript, .listPeers,
.addressUpdate, .meshRoster,
@@ -36,8 +36,12 @@ struct TranscriptItem: Identifiable, Equatable {
case error(message: String)
/// 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`
/// for every non-lock note (and lock notes with no paths).
case note(text: String, icon: String?, lockEvent: Bool, lock: NoteLock? = nil)
/// for every non-lock note (and lock notes with no paths). `processStall` carries the
/// "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)
}
@@ -253,7 +257,7 @@ enum TranscriptProjection {
var locksByCall: [String: [NoteLock]] = [:]
var folded = Set<Int>()
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 }
// 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
@@ -557,7 +561,8 @@ enum TranscriptProjection {
if note.lockEvent && !showLockEvents { break }
items.append(.init(id: "note-\(event.seq)", seq: event.seq,
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):
guard showRaw else { break }
items.append(.init(id: "raw-\(event.seq)", seq: event.seq,
@@ -58,9 +58,16 @@ struct TranscriptRow: View {
case .error(let message):
Label(message, systemImage: "xmark.octagon.fill")
.font(.caption).foregroundStyle(Palette.danger)
case .note(let text, let icon, _, _):
case .note(let text, let icon, _, _, let processStall):
// A live "host command looks hung" alert reads as a distinct amber row with Kill /
// 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):
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
/// (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.