Merge nucleic/keen-river-shrew-4cdk into dev
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user