diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index d4eba25..bbec275 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -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 = [] + + /// 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 { + 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, diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift index f75ebdb..4423260 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift @@ -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() 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, diff --git a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift index 548b2c8..1da85a4 100644 --- a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift +++ b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift @@ -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, _, _): - Label(text, systemImage: icon ?? "arrow.triangle.branch") - .font(.caption).foregroundStyle(.secondary) + 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.