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",