From 5a89e52eaf89e4cb44b1679ebaed007f5f8117c5 Mon Sep 17 00:00:00 2001 From: Byron Guina Date: Sat, 14 Mar 2026 18:45:38 -0500 Subject: [PATCH] feat: build session detail view from snapshots and events The macOS Control Center had a live sessions list, but the detail pane still behaved like a placeholder. Operators could not reliably inspect a selected session's current state, plain-text output, and tool activity, and the selection could collapse once a workload left the live list. This change builds the native session detail surface on top of the selected-session read model from the daemon HTTP API. The monitoring store now preserves retained detail for terminal sessions without repeatedly refetching dead workloads, centralizes lifecycle normalization on the selection detail model, and keeps sanitized plain-text event output bounded in memory. The SwiftUI detail pane now renders stable session identity, lifecycle state, metadata, tool activity, and recent plain-text output, with regression coverage for retained-session behavior and event sanitization. Verification: swift test (macos/ControlCenter) --- .../ControlCenterRootView.swift | 132 ++++++++++--- .../MonitoringStore.swift | 179 ++++++++++++++++-- .../MonitoringStoreTests.swift | 59 +++++- 3 files changed, 335 insertions(+), 35 deletions(-) diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift index 4a54a02..0aa4061 100644 --- a/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift @@ -469,8 +469,11 @@ private struct DetailColumn: View { case .sessions: if let detail = monitoring.selectedDetail { let sessionRoute = detail.session.sessionID.nonEmptyValue ?? "pending session" - let lifecycle = detail.agent.lifecycleState.nonEmptyValue ?? detail.session.status.nonEmptyValue ?? "unknown" - return "Showing live status, tool activity, and recent events for \(sessionRoute) in \(lifecycle) state." + let lifecycle = detail.lifecycleLabel + if detail.isLive { + return "Showing live status, tool activity, and plain-text session output for \(sessionRoute) in \(lifecycle) state." + } + return "Showing retained session detail for \(sessionRoute). The live workload exited, but recent output and tool activity remain available in \(lifecycle) state." } return monitoring.note case .queue: @@ -524,11 +527,15 @@ private struct DetailHighlightsPanel: View { case .sessions: if let detail = monitoring.selectedDetail { let pidLabel = detail.agent.pid > 0 ? String(detail.agent.pid) : "pending" + let liveSummary = detail.isLive + ? "Selected workload is live in the monitoring list." + : "Selected workload has exited; the pane is holding the last readable session detail." return [ "Selected workload: \(detail.workloadID) on pid \(pidLabel).", "Session route: \(detail.session.serverRef.nonEmptyValue ?? "pending server") / \(detail.session.sessionID.nonEmptyValue ?? "pending session").", - "Task lane: \(detail.agent.taskID.nonEmptyValue ?? detail.session.workRef.nonEmptyValue ?? "manual spawn"). Lifecycle: \(detail.agent.lifecycleState.nonEmptyValue ?? detail.agent.state.nonEmptyValue ?? "unknown").", - "Recent tool calls: \(detail.toolCalls.count). Event lines cached: \(detail.eventLines.count).", + "Task lane: \(detail.agent.taskID.nonEmptyValue ?? detail.session.workRef.nonEmptyValue ?? "manual spawn"). Lifecycle: \(detail.lifecycleLabel).", + "Recent tool calls: \(detail.toolCalls.count). Plain-text output lines cached: \(detail.eventLines.count).", + liveSummary, detail.errors.isEmpty ? "No daemon-reported errors on the selected workload." : "Daemon reported \(detail.errors.count) error\(detail.errors.count == 1 ? "" : "s") for this workload." ] } @@ -557,6 +564,26 @@ private struct DetailHighlightsPanel: View { } } +private func monitoringLifecycleTone(for detail: MonitoringSelectionDetail) -> Color { + switch detail.lifecycleLabel.lowercased() { + case "completed", "complete", "idle": + return ShellPalette.moss + case "failed", "error", "errored", "crashed": + return ShellPalette.ember + case "exited": + return ShellPalette.brass + default: + return detail.isLive ? ShellPalette.moss : ShellPalette.brass + } +} + +private func monitoringTimestampLabel(_ date: Date?) -> String { + guard let date else { + return "Pending" + } + return date.formatted(date: .abbreviated, time: .shortened) +} + private struct SectionPreview: View { let section: ShellSection let selectedCard: ShellCard @@ -1092,18 +1119,47 @@ private struct SessionsDetailPanel: View { var body: some View { if let detail = snapshot.selectedDetail { VStack(alignment: .leading, spacing: 14) { - HStack(spacing: 12) { - DetailPill(label: "work", value: detail.agent.taskID.nonEmptyValue ?? detail.session.workRef.nonEmptyValue ?? detail.workloadID) - DetailPill(label: "session", value: detail.session.sessionID.nonEmptyValue ?? "pending") - DetailPill(label: "origin", value: detail.session.originType.nonEmptyValue ?? "unknown") - DetailPill(label: "status", value: detail.agent.lifecycleState.nonEmptyValue ?? detail.session.status.nonEmptyValue ?? "unknown") + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text(detail.agent.taskTitle.nonEmptyValue ?? detail.session.workRef.nonEmptyValue ?? detail.workloadID) + .font(.system(size: 24, weight: .bold, design: .serif)) + .foregroundStyle(ShellPalette.ink) + Text(detail.session.sessionID.nonEmptyValue ?? "Waiting for session route") + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .foregroundStyle(ShellPalette.mutedInk) + Text(detail.isLive + ? "The detail pane is following the live workload directly from the daemon read model." + : "The workload dropped out of the live list, but the selected session stays open with its last readable output and tool activity.") + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundStyle(ShellPalette.mutedInk) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + VStack(alignment: .trailing, spacing: 8) { + MiniChip(text: detail.lifecycleLabel, tone: monitoringLifecycleTone(for: detail)) + if !detail.isLive { + MiniChip(text: "Retained detail", tone: ShellPalette.brass) + } + } } - HStack(spacing: 12) { - DetailPill(label: "agent", value: detail.workloadID) - DetailPill(label: "pid", value: detail.agent.pid > 0 ? String(detail.agent.pid) : "pending") - DetailPill(label: "server", value: detail.session.serverRef.nonEmptyValue ?? "pending") - DetailPill(label: "attach", value: detail.session.attachable ? "ready" : "pending") + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 12, alignment: .leading), + GridItem(.flexible(), spacing: 12, alignment: .leading) + ], + spacing: 12 + ) { + SessionFactCard(label: "Workload", value: detail.workloadID) + SessionFactCard(label: "Work ref", value: detail.agent.taskID.nonEmptyValue ?? detail.session.workRef.nonEmptyValue ?? "Manual spawn") + SessionFactCard(label: "Session", value: detail.session.sessionID.nonEmptyValue ?? "Pending") + SessionFactCard(label: "Origin", value: detail.session.originType.nonEmptyValue ?? "Unknown") + SessionFactCard(label: "Server", value: detail.session.serverRef.nonEmptyValue ?? "Pending") + SessionFactCard(label: "Directory", value: detail.session.directory.nonEmptyValue ?? "Pending") + SessionFactCard(label: "PID", value: detail.agent.pid > 0 ? String(detail.agent.pid) : "Pending") + SessionFactCard(label: "Attach", value: detail.session.attachable ? "Ready" : "Pending") + SessionFactCard(label: "Last activity", value: monitoringTimestampLabel(detail.agent.lastActivityAt ?? detail.session.lastSeenAt)) + SessionFactCard(label: "Updated", value: monitoringTimestampLabel(detail.session.updatedAt)) } if let lastLog = detail.agent.lastLog.nonEmptyValue { @@ -1115,11 +1171,15 @@ private struct SessionsDetailPanel: View { } } - if !detail.toolCalls.isEmpty { - VStack(alignment: .leading, spacing: 10) { - Text("Recent tool calls") - .font(.system(size: 12, weight: .bold, design: .monospaced)) + VStack(alignment: .leading, spacing: 10) { + Text("Tool activity") + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .foregroundStyle(ShellPalette.mutedInk) + if detail.toolCalls.isEmpty { + Text("No tool calls are visible yet for this session.") + .font(.system(size: 13, weight: .medium, design: .rounded)) .foregroundStyle(ShellPalette.mutedInk) + } else { ForEach(Array(detail.toolCalls.prefix(6).enumerated()), id: \.offset) { _, call in ToolCallRow(call: call) } @@ -1127,16 +1187,16 @@ private struct SessionsDetailPanel: View { } VStack(alignment: .leading, spacing: 10) { - Text("Recent events") + Text("Recent session output") .font(.system(size: 12, weight: .bold, design: .monospaced)) .foregroundStyle(ShellPalette.mutedInk) if detail.eventLines.isEmpty { - Text("No daemon event lines captured yet for this selection.") + Text("No plain-text session output is cached yet for this selection.") .font(.system(size: 13, weight: .medium, design: .rounded)) .foregroundStyle(ShellPalette.mutedInk) } else { - ForEach(Array(detail.eventLines.suffix(8).enumerated()), id: \.offset) { _, line in - EventLineRow(line: line) + ForEach(Array(detail.eventLines.suffix(12).enumerated()), id: \.offset) { _, line in + EventLineRow(line: line, tone: detail.isLive ? ShellPalette.moss : ShellPalette.brass) } } } @@ -1163,6 +1223,34 @@ private struct SessionsDetailPanel: View { } } +private struct SessionFactCard: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(label.uppercased()) + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .foregroundStyle(ShellPalette.mutedInk) + Text(value) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(ShellPalette.ink) + .textSelection(.enabled) + .lineLimit(3) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 18) + .fill(Color.white.opacity(0.22)) + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke(ShellPalette.panelBorder, lineWidth: 1) + ) + ) + } +} + private struct QueueDetailPanel: View { let snapshot: MonitoringSnapshot diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift index 835f211..1a4d260 100644 --- a/macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift @@ -1,5 +1,7 @@ import Foundation +private let monitoringANSIEscapeRegex = try! NSRegularExpression(pattern: "\u{001B}\\[[0-9;]*[ -/]*[@-~]") + enum MonitoringConnectionPhase: String, Equatable { case connecting case connected @@ -42,6 +44,47 @@ struct MonitoringSelectionDetail: Equatable, Sendable { let eventLines: [String] let lastEventTimestamp: Int64 let errors: [String] + let isLive: Bool + + var lifecycleLabel: String { + Self.normalizedLifecycleLabel( + agentLifecycleState: agent.lifecycleState.nonEmptyValue, + agentState: agent.state.nonEmptyValue, + sessionStatus: session.status.nonEmptyValue, + isLive: isLive + ) + } + + var retainedLifecycleLabel: String { + Self.normalizedLifecycleLabel( + agentLifecycleState: agent.lifecycleState.nonEmptyValue, + agentState: agent.state.nonEmptyValue, + sessionStatus: session.status.nonEmptyValue, + isLive: false + ) + } + + static func isTerminalLifecycle(_ value: String) -> Bool { + switch value.lowercased() { + case "complete", "completed", "crashed", "error", "errored", "exited", "failed", "idle": + return true + default: + return false + } + } + + private static func normalizedLifecycleLabel( + agentLifecycleState: String?, + agentState: String?, + sessionStatus: String?, + isLive: Bool + ) -> String { + let candidate = agentLifecycleState ?? agentState ?? sessionStatus ?? (isLive ? "unknown" : "exited") + if isLive || isTerminalLifecycle(candidate) { + return candidate + } + return "exited" + } } struct MonitoringSnapshot: Equatable, Sendable { @@ -78,6 +121,7 @@ struct MonitoringSnapshot: Equatable, Sendable { final class MonitoringStore: ObservableObject { @Published private(set) var snapshot: MonitoringSnapshot + private static let retainedEventLineLimit = 200 private struct SelectionAnchor: Equatable { let workloadID: String let sessionID: String? @@ -167,15 +211,27 @@ final class MonitoringStore: ObservableObject { do { let status = try await controller.fetchStatus(daemonURL: context.daemonURL) let workloads = orderedWorkloads(incoming: buildWorkloads(status: status), previous: snapshot.workloads) - let selectedWorkloadID = resolvedSelectionID(workloads: workloads) + let selectedWorkloadID = resolvedSelectionID( + workloads: workloads, + previousSelection: snapshot.selectedDetail + ) var selectedDetail = snapshot.selectedDetail let reconnected = needsAuthoritativeReload || snapshot.phase != .connected if let selectedWorkloadID { - selectedDetail = try await refreshSelectedDetail( - workloadID: selectedWorkloadID, - previous: reconnected ? nil : snapshot.selectedDetail - ) + let selectedWorkloadIsLive = workloads.contains { $0.id == selectedWorkloadID } + if !selectedWorkloadIsLive, let previous = snapshot.selectedDetail { + selectedDetail = retainedTerminalDetail( + from: previous, + workloadID: selectedWorkloadID + ) + } else { + selectedDetail = try await refreshSelectedDetail( + workloadID: selectedWorkloadID, + previous: reconnected ? nil : snapshot.selectedDetail, + isLive: selectedWorkloadIsLive + ) + } } else { selectedDetail = nil } @@ -193,9 +249,12 @@ final class MonitoringStore: ObservableObject { lastError: status.errors.last?.nonEmptyValue, updatedAt: .now ) - selectionAnchor = selectedWorkloadID.flatMap { workloadID in + selectionAnchor = selectedWorkloadID.map { workloadID in guard let workload = workloads.first(where: { $0.id == workloadID }) else { - return SelectionAnchor(workloadID: workloadID, sessionID: selectedDetail?.session.sessionID.nonEmptyValue) + return SelectionAnchor( + workloadID: workloadID, + sessionID: selectedDetail?.session.sessionID.nonEmptyValue + ) } return SelectionAnchor(workloadID: workloadID, sessionID: workload.sessionID.nonEmptyValue) } @@ -208,7 +267,8 @@ final class MonitoringStore: ObservableObject { private func refreshSelectedDetail( workloadID: String, - previous: MonitoringSelectionDetail? + previous: MonitoringSelectionDetail?, + isLive: Bool ) async throws -> MonitoringSelectionDetail { let detail = try await controller.fetchAgentDetail( daemonURL: context.daemonURL, @@ -223,7 +283,10 @@ final class MonitoringStore: ObservableObject { afterTimestamp: afterTimestamp ) - let lines = (previous?.eventLines ?? []) + events.lines + let lines = mergedEventLines( + previous: previous?.eventLines ?? [], + incoming: events.lines + ) let lastEventTimestamp = max(previous?.lastEventTimestamp ?? 0, events.lastTS) return MonitoringSelectionDetail( @@ -233,7 +296,8 @@ final class MonitoringStore: ObservableObject { toolCalls: detail.toolCalls, eventLines: lines, lastEventTimestamp: lastEventTimestamp, - errors: detail.errors + errors: detail.errors, + isLive: isLive ) } @@ -322,7 +386,10 @@ final class MonitoringStore: ObservableObject { return lhs.id.localizedStandardCompare(rhs.id) == .orderedAscending } - private func resolvedSelectionID(workloads: [MonitoringWorkloadSummary]) -> String? { + private func resolvedSelectionID( + workloads: [MonitoringWorkloadSummary], + previousSelection: MonitoringSelectionDetail? + ) -> String? { if let sessionID = selectionAnchor?.sessionID, let match = workloads.first(where: { $0.sessionID == sessionID }) { return match.id @@ -331,12 +398,97 @@ final class MonitoringStore: ObservableObject { workloads.contains(where: { $0.id == workloadID }) { return workloadID } + if shouldRetainSelection(workloads: workloads, previousSelection: previousSelection), + let workloadID = selectionAnchor?.workloadID { + return workloadID + } guard let preferred = workloads.first else { return nil } return preferred.id } + private func shouldRetainSelection( + workloads: [MonitoringWorkloadSummary], + previousSelection: MonitoringSelectionDetail? + ) -> Bool { + guard let previousSelection, + let anchoredWorkloadID = selectionAnchor?.workloadID, + anchoredWorkloadID == previousSelection.workloadID + else { + return false + } + let previousSessionID = previousSelection.session.sessionID.nonEmptyValue + if let anchoredSessionID = selectionAnchor?.sessionID, + anchoredSessionID != previousSessionID { + return false + } + return !workloads.contains(where: { $0.id == anchoredWorkloadID }) + } + + private func mergedEventLines(previous: [String], incoming: [String]) -> [String] { + let sanitizedIncoming = incoming.compactMap { line in + line.strippingANSIEscapeCodes.nonEmptyValue + } + let merged = previous + sanitizedIncoming + guard merged.count > Self.retainedEventLineLimit else { + return merged + } + return Array(merged.suffix(Self.retainedEventLineLimit)) + } + + private func retainedTerminalDetail( + from previous: MonitoringSelectionDetail, + workloadID: String + ) -> MonitoringSelectionDetail { + assert(previous.workloadID == workloadID, "retained detail must match the selected workload") + let lifecycleState = previous.retainedLifecycleLabel + assert( + MonitoringSelectionDetail.isTerminalLifecycle(lifecycleState), + "retained detail must normalize to a terminal lifecycle" + ) + let lastSeenAt = previous.agent.lastActivityAt + ?? previous.session.lastSeenAt + ?? previous.session.updatedAt + + return MonitoringSelectionDetail( + workloadID: workloadID, + session: DaemonSessionMetadataPayload( + serverRef: previous.session.serverRef, + sessionID: previous.session.sessionID, + directory: previous.session.directory, + project: previous.session.project, + originType: previous.session.originType, + workRef: previous.session.workRef, + agentID: previous.session.agentID, + status: lifecycleState, + createdAt: previous.session.createdAt, + lastSeenAt: lastSeenAt, + updatedAt: previous.session.updatedAt, + attachable: previous.session.attachable + ), + agent: DaemonAgentStatusPayload( + id: previous.agent.id, + taskID: previous.agent.taskID, + role: previous.agent.role, + pid: previous.agent.pid, + spawnTime: previous.agent.spawnTime, + taskTitle: previous.agent.taskTitle, + lastLog: previous.agent.lastLog, + sessionID: previous.agent.sessionID, + state: lifecycleState, + lifecycleState: lifecycleState, + lastActivityAt: lastSeenAt, + attentionNeeded: false + ), + toolCalls: previous.toolCalls, + eventLines: previous.eventLines, + lastEventTimestamp: previous.lastEventTimestamp, + errors: previous.errors, + isLive: false + ) + } + private func monitoringNote(for status: DaemonStatusPayload, workloadCount: Int) -> String { var parts = ["Monitoring connected for \(status.project.nonEmptyValue ?? context.projectName)."] if let poolMode = status.poolMode.nonEmptyValue { @@ -366,4 +518,9 @@ private extension String { let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } + + var strippingANSIEscapeCodes: String { + let range = NSRange(startIndex.. DaemonStatusPayload { + DaemonStatusPayload( + poolSize: 0, + poolMode: "active", + project: "aetherflow", + spawnPolicy: "manual", + agents: [], + spawns: [], + queue: [], + errors: [] + ) + } + private static func detail( agentID: String = "agent-1", workRef: String = "ts-c9cdd2",