diff --git a/Glint/Agent/AgentPaneSummary.swift b/Glint/Agent/AgentPaneSummary.swift index 7324731..24147ea 100644 --- a/Glint/Agent/AgentPaneSummary.swift +++ b/Glint/Agent/AgentPaneSummary.swift @@ -58,6 +58,13 @@ func agentElapsedLabel(since start: Date, now: Date) -> String { return "\(total / 3600)h\((total % 3600) / 60)m" } +/// Busy panes advance against the live clock. Attention states that end or +/// pause the turn keep the duration captured by their last hook instead of +/// making a completed/error/reply-wait row look as if it were still running. +func agentElapsedReferenceDate(status: PaneAgentStatus, updatedAt: Date, now: Date) -> Date { + WorkspaceStore.isBusyStatus(status) ? now : updatedAt +} + // MARK: - Cluster (always-visible glance layer) /// Up to `cap` attention-sorted status dots — one per live agent pane. This @@ -176,7 +183,14 @@ private struct PaneSummaryRow: View { ) TimelineView(.periodic(from: .now, by: 1)) { ctx in - Text(agentElapsedLabel(since: info.since, now: ctx.date)) + Text(agentElapsedLabel( + since: info.since, + now: agentElapsedReferenceDate( + status: info.status, + updatedAt: info.updatedAt, + now: ctx.date + ) + )) .font(.system(size: 10, weight: .medium, design: .monospaced)) .foregroundStyle(Theme.text4) .lineLimit(1) diff --git a/Glint/Workspace/WorkspaceStore.swift b/Glint/Workspace/WorkspaceStore.swift index 0e65e95..26be390 100644 --- a/Glint/Workspace/WorkspaceStore.swift +++ b/Glint/Workspace/WorkspaceStore.swift @@ -4289,6 +4289,8 @@ extension WorkspaceStore { let status: PaneAgentStatus /// Turn start — drives the "2m" elapsed label (total turn time). let since: Date + /// Last hook time — freezes the elapsed label once the turn stops. + let updatedAt: Date var id: PaneID { paneID } } @@ -4304,7 +4306,8 @@ extension WorkspaceStore { guard let e = paneAgentState[key], e.status != .idle else { continue } out.append((AgentPaneInfo(paneID: item.pane, number: idx + 1, label: item.label, kind: e.kind, - status: e.status, since: e.turnStartedAt), + status: e.status, since: e.turnStartedAt, + updatedAt: e.updatedAt), e.updatedAt)) } out.sort { @@ -4337,7 +4340,7 @@ extension WorkspaceStore { /// A turn is actively running in these states — used to anchor the turn /// clock (set on the first non-busy → busy transition, kept through the /// turn). `.justCompleted`/`.failed`/`.idle` are turn-end / no-turn. - static func isBusyStatus(_ s: PaneAgentStatus) -> Bool { + nonisolated static func isBusyStatus(_ s: PaneAgentStatus) -> Bool { switch s { case .thinking, .tool, .compacting, .needsPermission: return true case .justCompleted, .failed, .needsReply, .idle: return false diff --git a/GlintTests/AgentHookRoutingTests.swift b/GlintTests/AgentHookRoutingTests.swift index a3f51fb..df3f42b 100644 --- a/GlintTests/AgentHookRoutingTests.swift +++ b/GlintTests/AgentHookRoutingTests.swift @@ -2,6 +2,24 @@ import XCTest @testable import Glint final class AgentHookRoutingTests: XCTestCase { + func testCompletedAgentElapsedTimerStopsAtCompletion() { + let completedAt = Date(timeIntervalSince1970: 125) + let muchLater = Date(timeIntervalSince1970: 900) + + XCTAssertEqual( + agentElapsedReferenceDate(status: .justCompleted, + updatedAt: completedAt, + now: muchLater), + completedAt + ) + XCTAssertEqual( + agentElapsedReferenceDate(status: .thinking, + updatedAt: completedAt, + now: muchLater), + muchLater + ) + } + func testAttentionRankDoesNotLetThinkingHideCompletedSibling() { XCTAssertEqual( PaneAgentStatus.bestAttentionRank(in: [.thinking, .justCompleted]),