From bf93e7fdbdda6ff00ba033a6c0f9643b02befa59 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 4 Jun 2026 15:29:57 +0800 Subject: [PATCH 1/2] {"schema":"decodex/commit/1","summary":"Refine desktop lane popover","authority":"manual"} --- .../Sources/DecodexApp/AccountPanelView.swift | 341 ++++++++++++++---- 1 file changed, 273 insertions(+), 68 deletions(-) diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index abbd78a9..6c71185d 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -1563,8 +1563,8 @@ struct AccountRunChipView: View { } .popover(isPresented: $showsPopover, arrowEdge: .trailing) { OperatorLanePopoverView(run: run) - .frame(width: 360) - .padding(8) + .frame(width: 452) + .padding(6) } } @@ -2590,14 +2590,13 @@ struct OperatorStatusStripView: View { struct OperatorLanePopoverView: View { let run: OperatorRunStatus - @Environment(\.colorScheme) private var colorScheme var body: some View { - VStack(alignment: .leading, spacing: 7) { + VStack(alignment: .leading, spacing: 5) { header - if let projectReadout { - OperatorLaneReadoutRow(title: "Project", items: [projectReadout]) + if hasReadoutContent { + OperatorLaneReadoutDivider() } if let modelBucket { @@ -2608,42 +2607,42 @@ struct OperatorLanePopoverView: View { total: formatActivityDuration(totalWallSeconds) ?? "0s", barShare: bucketShare(modelBucket) ) - OperatorLaneReadoutDivider() + if detailBuckets.isEmpty == false || contextReadoutRows.isEmpty == false { + OperatorLaneReadoutDivider() + } } - ForEach(detailBuckets) { bucket in - OperatorLaneReadoutRow(title: rawPanelToken(bucket.name), items: bucketReadoutItems(bucket)) - } + VStack(alignment: .leading, spacing: 4) { + ForEach(detailBuckets) { bucket in + OperatorLaneReadoutRow(title: rawPanelToken(bucket.name), items: bucketReadoutItems(bucket)) + } - if contextReadoutItems.isEmpty == false { - OperatorLaneReadoutDivider() - OperatorLaneReadoutRow(title: "Context", items: contextReadoutItems) + if contextReadoutRows.isEmpty == false { + OperatorLaneReadoutDivider() + ForEach(contextReadoutRows) { row in + OperatorLaneReadoutRow(title: row.title, items: row.items) + } + } + + if detailBuckets.isEmpty, contextReadoutRows.isEmpty, fallbackRunReadoutItems.isEmpty == false { + OperatorLaneReadoutRow(title: "Run", items: fallbackRunReadoutItems) + } } } - .padding(9) - .modernGlassSurface(cornerRadius: 12, depth: .section) + .padding(.horizontal, 8) + .padding(.vertical, 7) + .modernGlassSurface(cornerRadius: 12, depth: .panel) .accessibilityLabel("Lane activity for \(run.compactTitle)") } - private var tint: Color { - if run.hasAttentionTone { - return PanelPalette.warning(colorScheme) - } - if run.isWaiting { - return PanelPalette.secondaryText(colorScheme) - } - - return PanelPalette.routeAccent(colorScheme) - } - private var activity: OperatorChildAgentActivity? { run.childAgentActivity } private var currentSummary: String { - guard let activity else { - return "Waiting for child activity" - } + guard let activity else { + return "Waiting for child activity" + } let label = panelTrimmed(activity.currentDetail) ?? panelTrimmed(activity.currentBucket).map(rawPanelToken) @@ -2656,17 +2655,61 @@ struct OperatorLanePopoverView: View { } private var header: some View { - OperatorLaneReadoutRow(title: "Activity", items: [ - OperatorLaneReadoutItem(label: nil, value: currentSummary, tone: .primary), - ], trailing: run.compactTitle) + OperatorLaneHeaderReadoutView( + status: currentSummary, + project: projectTitle + ) } - private var projectReadout: OperatorLaneReadoutItem? { - guard let projectName = panelTrimmed(run.projectDisplayName) ?? panelTrimmed(run.projectID) else { - return nil + private var projectTitle: String? { + panelTrimmed(run.projectDisplayName) ?? panelTrimmed(run.projectID) + } + + private var hasReadoutContent: Bool { + modelBucket != nil + || detailBuckets.isEmpty == false + || contextReadoutRows.isEmpty == false + || fallbackRunReadoutItems.isEmpty == false + } + + private var fallbackRunReadoutItems: [OperatorLaneReadoutItem] { + guard let activity else { + return [] + } + + var items = [ + OperatorLaneReadoutItem( + label: "wall", + value: formatActivityDuration(activity.wallSeconds) ?? "0s" + ), + OperatorLaneReadoutItem( + label: "events", + value: formatCompactCount(activity.eventCount) + ), + OperatorLaneReadoutItem( + label: "input", + value: "\(formatCompactCount(activity.inputTokensCumulative)) tok" + ), + OperatorLaneReadoutItem( + label: "output", + value: "\(formatCompactCount(activity.outputTokensCumulative)) tok" + ), + OperatorLaneReadoutItem( + label: "tool calls", + value: formatCompactCount(activity.toolCallCount) + ), + ] + + if let largestOutput = activity.largestToolOutputBytes, largestOutput > 0 { + items.append( + OperatorLaneReadoutItem( + label: "largest output", + value: formatCompactBytes(largestOutput) + ) + ) } - return OperatorLaneReadoutItem(label: nil, value: projectName, tone: .primary) + return items } private var modelBucket: OperatorChildAgentBucket? { @@ -2700,7 +2743,16 @@ struct OperatorLanePopoverView: View { } } - private var contextReadoutItems: [OperatorLaneReadoutItem] { + private var contextReadoutRows: [OperatorLaneReadoutLine] { + let rows = [ + OperatorLaneReadoutLine(title: "Context", items: contextTokenReadoutItems), + OperatorLaneReadoutLine(title: "Tools", items: contextToolReadoutItems), + ] + + return rows.filter { $0.items.isEmpty == false } + } + + private var contextTokenReadoutItems: [OperatorLaneReadoutItem] { guard let activity else { return [] } @@ -2715,12 +2767,25 @@ struct OperatorLanePopoverView: View { if activity.inputTokensCumulative > 0 { items.append(OperatorLaneReadoutItem(label: "input", value: "\(formatCompactCount(activity.inputTokensCumulative)) tok")) } + + return items + } + + private var contextToolReadoutItems: [OperatorLaneReadoutItem] { + guard let activity else { + return [] + } + + var items = [OperatorLaneReadoutItem]() if activity.toolCallCount > 0 { - items.append(OperatorLaneReadoutItem(label: "tool_calls", value: formatCompactCount(activity.toolCallCount))) + items.append(OperatorLaneReadoutItem(label: "tool calls", value: formatCompactCount(activity.toolCallCount))) } if let largestOutput = activity.largestToolOutputBytes, largestOutput > 0 { items.append(OperatorLaneReadoutItem(label: "largest output", value: formatCompactBytes(largestOutput))) } + if let largestTool = panelTrimmed(activity.largestToolOutputTool) { + items.append(OperatorLaneReadoutItem(label: "largest tool", value: largestTool)) + } return items } @@ -2734,7 +2799,7 @@ struct OperatorLanePopoverView: View { 1, activity?.wallSeconds ?? 0, bucketRows.reduce(0) { $0 + max(0, $1.wallSeconds) } - ) + ) } private func bucketReadoutItems(_ bucket: OperatorChildAgentBucket) -> [OperatorLaneReadoutItem] { @@ -2756,7 +2821,7 @@ struct OperatorLanePopoverView: View { } } else { if bucket.toolCallCount > 0 { - items.append(OperatorLaneReadoutItem(label: "tool_calls", value: formatCompactCount(bucket.toolCallCount))) + items.append(OperatorLaneReadoutItem(label: "tool calls", value: formatCompactCount(bucket.toolCallCount))) } if bucket.outputBytes > 0 { items.append(OperatorLaneReadoutItem(label: "output bytes", value: formatCompactBytes(bucket.outputBytes))) @@ -2802,6 +2867,50 @@ struct OperatorLanePopoverView: View { } } +struct OperatorLaneHeaderReadoutView: View { + let status: String + let project: String? + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(status) + .font(PanelFont.laneTitle) + .foregroundStyle(PanelPalette.primaryText(colorScheme).opacity(0.94)) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + + if let project = panelTrimmed(project) { + Text(project) + .font(PanelFont.laneDetail) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.82)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .help(project) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct OperatorLaneReadoutLine: Identifiable { + let title: String + let items: [OperatorLaneReadoutItem] + + var id: String { + title + } +} + +private enum OperatorLaneReadoutLayout { + static let titleWidth: CGFloat = 72 + static let columnSpacing: CGFloat = 7 + static let itemSpacing: CGFloat = 8 + static let itemRowSpacing: CGFloat = 2 + static let wideLabelWidth: CGFloat = 92 +} + struct OperatorLaneReadoutItem: Identifiable { enum Tone { case primary @@ -2821,6 +2930,32 @@ struct OperatorLaneReadoutItem: Identifiable { var id: String { "\(label ?? "value")-\(value)" } + + var usesWideRow: Bool { + let normalizedLabel = label?.lowercased() ?? "" + return value.count > 30 || normalizedLabel == "largest tool" + } + + var preferredWidth: CGFloat { + switch label?.lowercased() { + case "events": + return 62 + case "wall": + return 70 + case "tool calls": + return 84 + case "input", "output", "current", "peak": + return 108 + case "output bytes": + return 122 + case "largest output": + return 138 + case "largest tool": + return 148 + default: + return 96 + } + } } struct OperatorLaneReadoutRow: View { @@ -2836,29 +2971,49 @@ struct OperatorLaneReadoutRow: View { } var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(title) - .font(PanelFont.lanePopoverLabel) - .foregroundStyle(PanelPalette.secondaryText(colorScheme)) - .lineLimit(1) - .frame(width: 62, alignment: .leading) + VStack(alignment: .leading, spacing: OperatorLaneReadoutLayout.itemRowSpacing) { + HStack(alignment: .firstTextBaseline, spacing: OperatorLaneReadoutLayout.columnSpacing) { + Text(title) + .font(PanelFont.lanePopoverLabel) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.88)) + .lineLimit(1) + .frame(width: OperatorLaneReadoutLayout.titleWidth, alignment: .leading) + + if inlineItems.isEmpty == false { + HStack(alignment: .firstTextBaseline, spacing: OperatorLaneReadoutLayout.itemSpacing) { + ForEach(inlineItems) { item in + OperatorLaneReadoutItemView(item: item) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Spacer(minLength: 0) + } - OperatorLaneReadoutFlowLayout(spacing: 8, rowSpacing: 4) { - ForEach(items) { item in - OperatorLaneReadoutItemView(item: item) + if let trailing = panelTrimmed(trailing) { + Text(trailing) + .font(PanelFont.lanePopoverMeta) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.7)) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 72, alignment: .trailing) } } - .frame(maxWidth: .infinity, alignment: .leading) - if let trailing = panelTrimmed(trailing) { - Text(trailing) - .font(PanelFont.lanePopoverMeta) - .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.76)) - .lineLimit(1) - .truncationMode(.middle) - .frame(maxWidth: 78, alignment: .trailing) + ForEach(wideItems) { item in + OperatorLaneWideReadoutItemView(item: item) + .padding(.leading, OperatorLaneReadoutLayout.titleWidth + OperatorLaneReadoutLayout.columnSpacing) } } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var inlineItems: [OperatorLaneReadoutItem] { + items.filter { $0.usesWideRow == false } + } + + private var wideItems: [OperatorLaneReadoutItem] { + items.filter(\.usesWideRow) } } @@ -2871,16 +3026,16 @@ struct OperatorLaneProgressReadoutRow: View { @Environment(\.colorScheme) private var colorScheme var body: some View { - HStack(alignment: .center, spacing: 8) { + HStack(alignment: .center, spacing: OperatorLaneReadoutLayout.columnSpacing) { Text(title) .font(PanelFont.lanePopoverLabel) - .foregroundStyle(PanelPalette.secondaryText(colorScheme)) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.88)) .lineLimit(1) - .frame(width: 62, alignment: .leading) + .frame(width: OperatorLaneReadoutLayout.titleWidth, alignment: .leading) UsageGlassTrackView( progress: barShare, - tint: PanelPalette.usageCyan(colorScheme), + tint: PanelPalette.routeAccent(colorScheme).opacity(colorScheme == .dark ? 0.84 : 0.8), markers: [0.25, 0.5, 0.75], alertMarker: nil ) @@ -2889,12 +3044,12 @@ struct OperatorLaneProgressReadoutRow: View { Text("\(percent)% ยท \(elapsed) / \(total)") .font(PanelFont.lanePopoverMeta) - .foregroundStyle(PanelPalette.secondaryText(colorScheme)) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.86)) .monospacedDigit() .lineLimit(1) .fixedSize(horizontal: true, vertical: false) } - .frame(height: 22) + .frame(height: 19) } } @@ -2907,7 +3062,7 @@ struct OperatorLaneReadoutItemView: View { if let label = item.label { Text(label) .font(PanelFont.lanePopoverMeta) - .foregroundStyle(PanelPalette.secondaryText(colorScheme)) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.84)) .lineLimit(1) } @@ -2916,9 +3071,59 @@ struct OperatorLaneReadoutItemView: View { .foregroundStyle(valueColor) .monospacedDigit() .lineLimit(1) - .minimumScaleFactor(0.78) } - .fixedSize(horizontal: true, vertical: false) + .frame(width: item.preferredWidth, alignment: .leading) + .fixedSize(horizontal: false, vertical: false) + .help(accessibilityText) + } + + private var accessibilityText: String { + if let label = item.label { + return "\(label) \(item.value)" + } + + return item.value + } + + private var valueColor: Color { + switch item.tone { + case .primary: + return PanelPalette.primaryText(colorScheme) + case .secondary: + return PanelPalette.primaryText(colorScheme).opacity(colorScheme == .dark ? 0.9 : 0.84) + } + } +} + +struct OperatorLaneWideReadoutItemView: View { + let item: OperatorLaneReadoutItem + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + if let label = item.label { + Text(label) + .font(PanelFont.lanePopoverMeta) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.84)) + .lineLimit(1) + .frame(width: OperatorLaneReadoutLayout.wideLabelWidth, alignment: .leading) + } + + Text(item.value) + .font(PanelFont.lanePopoverValue) + .foregroundStyle(valueColor) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + .help(accessibilityText) + } + + private var accessibilityText: String { + if let label = item.label { + return "\(label) \(item.value)" + } + + return item.value } private var valueColor: Color { @@ -2936,9 +3141,9 @@ struct OperatorLaneReadoutDivider: View { var body: some View { Rectangle() - .fill(PanelPalette.separator(colorScheme)) + .fill(PanelPalette.separator(colorScheme).opacity(colorScheme == .dark ? 0.82 : 0.92)) .frame(height: 0.5) - .padding(.vertical, 1) + .padding(.vertical, 0.5) } } From 8115c6492b2798e7e7e76b4ba534a3a468dda9a0 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 4 Jun 2026 15:44:47 +0800 Subject: [PATCH 2/2] {"schema":"decodex/commit/1","summary":"Stabilize desktop run activity snapshots","authority":"manual"} --- .../Sources/DecodexApp/AccountStore.swift | 7 ++- .../DecodexApp/OperatorSnapshotModels.swift | 13 ++++- .../DecodexAppTests/AccountModelTests.swift | 55 +++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift index f08b2baf..b8d67cdc 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift @@ -155,8 +155,6 @@ final class AccountStore: ObservableObject { do { try await connectOperatorSnapshotStream() } catch { - operatorSnapshot = nil - operatorSnapshotUpdatedAt = nil pendingRunActivity = nil } @@ -244,6 +242,11 @@ final class AccountStore: ObservableObject { return } + guard activity.shouldApply(to: operatorSnapshot) else { + pendingRunActivity = nil + + return + } pendingRunActivity = activity if let operatorSnapshot { diff --git a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift index cb148242..f942de6f 100644 --- a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift +++ b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift @@ -94,7 +94,7 @@ struct OperatorSnapshotResponse: Decodable, Sendable { if let activityRun = activityRunsByID[snapshotRun.runID] { return snapshotRun.mergingActivity(activityRun) } - if activeRunsComplete || activityRuns.isEmpty { + if activeRunsComplete { return nil } @@ -600,6 +600,17 @@ struct OperatorRunActivitySnapshot: Sendable { func merging(into snapshot: OperatorSnapshotResponse) -> OperatorSnapshotResponse { snapshot.mergingRunActivity(activeRuns, activeRunsComplete: activeRunsComplete) } + + func shouldApply(to snapshot: OperatorSnapshotResponse?) -> Bool { + guard let snapshot else { + return true + } + if activeRunsComplete, activeRuns.isEmpty, snapshot.activeRuns.isEmpty == false { + return false + } + + return true + } } struct OperatorChildAgentActivity: Decodable, Sendable { diff --git a/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift b/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift index df6e23aa..6b38e759 100644 --- a/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift +++ b/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift @@ -312,6 +312,39 @@ final class AccountModelTests: XCTestCase { XCTAssertEqual(merged.activeRuns(for: account).map(\.runID), ["run-689", "run-690"]) } + func testEmptyPartialRunActivityPreservesSnapshotActiveRuns() throws { + let account = makeAccount( + status: "available", + email: "copy@example.com", + accountFingerprint: "...123456" + ) + let snapshotPayload = """ + { + "active_runs": [ + { + "run_id": "run-689", + "issue_identifier": "XY-689", + "active_lease": true, + "account": { + "email": "copy@example.com", + "account_fingerprint": "...123456" + } + } + ] + } + """.data(using: .utf8)! + let snapshot = try JSONDecoder().decode(OperatorSnapshotResponse.self, from: snapshotPayload) + let overlay = OperatorRunActivitySnapshot( + activeRuns: [], + activeRunsComplete: false, + emittedAt: Date(timeIntervalSince1970: 30) + ) + let merged = overlay.merging(into: snapshot) + + XCTAssertEqual(merged.activeRuns.map(\.runID), ["run-689"]) + XCTAssertEqual(merged.activeRuns(for: account).map(\.runID), ["run-689"]) + } + func testCompleteRunActivityReplacesSnapshotActiveRuns() throws { let account = makeAccount( status: "available", @@ -405,6 +438,28 @@ final class AccountModelTests: XCTestCase { XCTAssertTrue(merged.activeRuns(for: account).isEmpty) } + func testCompleteEmptyRunActivityWaitsForSnapshotBeforeClearingVisibleRuns() throws { + let snapshotPayload = """ + { + "active_runs": [ + { + "run_id": "run-old", + "issue_identifier": "XY-672" + } + ] + } + """.data(using: .utf8)! + let snapshot = try JSONDecoder().decode(OperatorSnapshotResponse.self, from: snapshotPayload) + let overlay = OperatorRunActivitySnapshot( + activeRuns: [], + activeRunsComplete: true, + emittedAt: Date(timeIntervalSince1970: 30) + ) + + XCTAssertFalse(overlay.shouldApply(to: snapshot)) + XCTAssertTrue(overlay.shouldApply(to: nil)) + } + func testOperatorSnapshotWarningSummaryUsesRawWarningToken() throws { let payload = """ {