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 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):
|
||||||
|
// 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")
|
Label(text, systemImage: icon ?? "arrow.triangle.branch")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.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.
|
||||||
|
|||||||
Reference in New Issue
Block a user