Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/swift-ios/App/NativeFeatureClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4270,7 +4270,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
backgroundWorkIsActive: backgroundWorkIsActive,
fallbackUpdatedAt: thread.updatedAt
),
latestTurnCompletedAt: thread.latestTurn?.completedAt.map(parseDate),
latestTurnCompletedAt: thread.latestTurn?.completedAt.flatMap(parseValidDate),
settlementFacts: settlementFacts(
override: thread.settledOverride,
session: thread.session,
Expand Down Expand Up @@ -4351,7 +4351,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
backgroundWorkIsActive: backgroundWorkIsActive,
fallbackUpdatedAt: thread.updatedAt
),
latestTurnCompletedAt: thread.latestTurn?.completedAt.map(parseDate),
latestTurnCompletedAt: thread.latestTurn?.completedAt.flatMap(parseValidDate),
settlementFacts: settlementFacts(
override: thread.settledOverride,
session: thread.session,
Expand Down
74 changes: 73 additions & 1 deletion apps/swift-ios/Features/Workspace/DailyUXModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,57 @@ enum HomeWorkingDuration {
}
}

/// The completion age shown in a completed rich Home row.
///
/// Recent completions stay minute-granular because Home refreshes quiet rows every 60 seconds.
enum HomeDoneDuration {
static func compact(since date: Date, now: Date) -> String {
let minutes = elapsedMinutes(since: date, now: now)
guard minutes >= 1 else { return "now" }
guard minutes >= 60 else { return "\(minutes)m" }
let hours = minutes / 60
guard hours >= 24 else { return "\(hours)h \(minutes % 60)m" }
let days = hours / 24
guard days >= 7 else { return "\(days)d \(hours % 24)h" }
guard days >= 365 else { return "\(days / 7)w" }
return "\(days / 365)y"
}

static func accessibility(since date: Date, now: Date) -> String {
"Completed \(elapsedPhrase(since: date, now: now))"
}

private static func elapsedPhrase(since date: Date, now: Date) -> String {
let minutes = elapsedMinutes(since: date, now: now)
guard minutes >= 1 else { return "just now" }
guard minutes >= 60 else { return "\(unit(minutes, singular: "minute")) ago" }

let hours = minutes / 60
guard hours >= 24 else {
let remainingMinutes = minutes % 60
guard remainingMinutes > 0 else { return "\(unit(hours, singular: "hour")) ago" }
return "\(unit(hours, singular: "hour")), \(unit(remainingMinutes, singular: "minute")) ago"
}

let days = hours / 24
guard days < 7 else {
guard days >= 365 else { return "\(unit(days / 7, singular: "week")) ago" }
return "\(unit(days / 365, singular: "year")) ago"
}
let remainingHours = hours % 24
guard remainingHours > 0 else { return "\(unit(days, singular: "day")) ago" }
return "\(unit(days, singular: "day")), \(unit(remainingHours, singular: "hour")) ago"
}

private static func elapsedMinutes(since date: Date, now: Date) -> Int {
max(0, Int(now.timeIntervalSince(date))) / 60
}

private static func unit(_ value: Int, singular: String) -> String {
"\(value) \(singular)\(value == 1 ? "" : "s")"
}
}

extension FeatureThread {
var homeStatus: HomeThreadStatus {
switch state {
Expand Down Expand Up @@ -868,7 +919,9 @@ extension FeatureThread {

func homeRowStatusLabel(at now: Date) -> String {
switch homeStatus {
case .done, .ready:
case .done:
homeDoneDuration(at: now) ?? SidebarRelativeAge.compact(since: updatedAt, now: now)
case .ready:
SidebarRelativeAge.compact(since: updatedAt, now: now)
case .approval, .input, .working, .monitoring, .failed:
homeStatusLabel ?? SidebarRelativeAge.compact(since: updatedAt, now: now)
Expand All @@ -880,6 +933,25 @@ extension FeatureThread {
return HomeWorkingDuration.compact(since: workingStartedAt, now: now)
}

func homeDoneDuration(at now: Date) -> String? {
guard homeStatus == .done, let latestTurnCompletedAt else { return nil }
return HomeDoneDuration.compact(since: latestTurnCompletedAt, now: now)
}

func homeDoneAccessibilityLabel(at now: Date) -> String? {
guard homeStatus == .done, let latestTurnCompletedAt else { return nil }
return HomeDoneDuration.accessibility(since: latestTurnCompletedAt, now: now)
}

func homeRowAccessibilityStatus(rich: Bool, at now: Date) -> String {
guard rich else { return homeStatusLabel ?? "Ready" }
if let completed = homeDoneAccessibilityLabel(at: now) { return completed }
if homeStatus == .done {
return "Done. \(SidebarRelativeAge.accessibility(since: updatedAt, now: now))"
}
return homeStatusLabel ?? "Ready"
}

var hasLiveWorkingDuration: Bool {
homeStatus == .working && workingStartedAt != nil
}
Expand Down
21 changes: 15 additions & 6 deletions apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -393,13 +393,17 @@ struct HomeThreadCollectionView: UIViewRepresentable {
private func configureAccessibility(_ cell: HomeCollectionCell, item: HomeCollectionItem) {
cell.accessibilityCustomActions = nil
switch item {
case let .thread(thread, context, _, isArchived, _):
case let .thread(thread, context, style, isArchived, _):
cell.isAccessibilityElement = true
cell.accessibilityTraits = selectedThreadID == thread.id
? [.button, .selected]
: .button
cell.accessibilityLabel = thread.title
cell.accessibilityValue = threadAccessibilityValue(thread, context: context)
cell.accessibilityValue = threadAccessibilityValue(
thread,
context: context,
style: style
)
cell.accessibilityHint = "Opens thread. More actions are available."
cell.accessibilityCustomActions = threadAccessibilityActions(
for: thread,
Expand Down Expand Up @@ -455,9 +459,10 @@ struct HomeThreadCollectionView: UIViewRepresentable {

private func threadAccessibilityValue(
_ thread: FeatureThread,
context: HomeThreadRowContext
context: HomeThreadRowContext,
style: FeatureThreadRow.Style
) -> String {
var status = thread.homeStatusLabel ?? "Ready"
var status = thread.homeRowAccessibilityStatus(rich: style == .rich, at: .now)
if let duration = thread.homeWorkingDuration(at: .now) {
status += " for \(duration)"
}
Comment thread
cursor[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -491,7 +496,7 @@ struct HomeThreadCollectionView: UIViewRepresentable {
threadID: String,
cell: HomeCollectionCell
) {
guard case let .thread(thread, context, _, _, _) = itemsByID[.thread(threadID)],
guard case let .thread(thread, context, style, _, _) = itemsByID[.thread(threadID)],
let indexPath = dataSource?.indexPath(for: .thread(threadID)),
collectionView?.cellForItem(at: indexPath) === cell else {
return
Expand All @@ -501,7 +506,11 @@ struct HomeThreadCollectionView: UIViewRepresentable {
} else {
pullRequestsByThreadID.removeValue(forKey: threadID)
}
cell.accessibilityValue = threadAccessibilityValue(thread, context: context)
cell.accessibilityValue = threadAccessibilityValue(
thread,
context: context,
style: style
)
}

private func threadAccessibilityActions(
Expand Down
3 changes: 2 additions & 1 deletion apps/swift-ios/Features/Workspace/WorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1316,7 +1316,8 @@ struct FeatureThreadRow: View {
}

private func accessibilityValue(at now: Date) -> String {
var values = [thread.homeStatusLabel ?? "Ready", "Project \(context.projectName)"]
let status = thread.homeRowAccessibilityStatus(rich: style == .rich, at: now)
var values = [status, "Project \(context.projectName)"]
values.append("Harness \(context.providerName)")
if let duration = thread.homeWorkingDuration(at: now) {
values.append("for \(duration)")
Expand Down
103 changes: 103 additions & 0 deletions apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,109 @@ struct HomeThreadMetadataTests {
#expect(idle.homeStatusAccessibilityLabel(at: now) == "Ready")
}

@Test
func completedRowsShowABareAgeMeasuredFromCompletion() {
let thread = FeatureThread(
id: "completed",
projectID: "project",
title: "Done task",
updatedAt: now.addingTimeInterval(-60),
state: .completed,
latestTurnCompletedAt: now.addingTimeInterval(-9_360)
)

#expect(thread.homeDoneDuration(at: now) == "2h 36m")
#expect(thread.homeRowStatusLabel(at: now) == "2h 36m")
#expect(
thread.homeRowAccessibilityStatus(rich: true, at: now)
== "Completed 2 hours, 36 minutes ago"
)
#expect(thread.homeRowAccessibilityStatus(rich: false, at: now) == "Done")
}

@Test
func doneDurationsAreMinuteGranularAndClampFutureCompletions() {
#expect(doneDuration(completedAtOffset: 30) == "now")
#expect(doneDuration(completedAtOffset: -30) == "now")
#expect(doneDuration(completedAtOffset: -59) == "now")
#expect(doneDuration(completedAtOffset: -60) == "1m")
#expect(doneDuration(completedAtOffset: -3_600) == "1h 0m")
#expect(doneDuration(completedAtOffset: -5_465) == "1h 31m")
#expect(doneDuration(completedAtOffset: -86_400) == "1d 0h")
#expect(doneDuration(completedAtOffset: -273_600) == "3d 4h")
#expect(doneDuration(completedAtOffset: -604_800) == "1w")
#expect(doneDuration(completedAtOffset: -31_449_600) == "52w")
#expect(doneDuration(completedAtOffset: -31_536_000) == "1y")
#expect(doneDuration(completedAtOffset: -63_072_000) == "2y")
}

@Test
func doneAccessibilityLabelsSpeakTheAgeInWords() {
#expect(doneAccessibilityLabel(completedAtOffset: -30) == "Completed just now")
#expect(doneAccessibilityLabel(completedAtOffset: -60) == "Completed 1 minute ago")
#expect(doneAccessibilityLabel(completedAtOffset: -120) == "Completed 2 minutes ago")
#expect(doneAccessibilityLabel(completedAtOffset: -3_600) == "Completed 1 hour ago")
#expect(
doneAccessibilityLabel(completedAtOffset: -9_360) == "Completed 2 hours, 36 minutes ago"
)
#expect(doneAccessibilityLabel(completedAtOffset: -86_400) == "Completed 1 day ago")
#expect(
doneAccessibilityLabel(completedAtOffset: -273_600) == "Completed 3 days, 4 hours ago"
)
#expect(doneAccessibilityLabel(completedAtOffset: -604_800) == "Completed 1 week ago")
#expect(doneAccessibilityLabel(completedAtOffset: -31_449_600) == "Completed 52 weeks ago")
#expect(doneAccessibilityLabel(completedAtOffset: -31_536_000) == "Completed 1 year ago")
#expect(doneAccessibilityLabel(completedAtOffset: -63_072_000) == "Completed 2 years ago")
}

@Test
func onlyCompletedThreadsWithACompletionTimeShowADoneDuration() {
let completedWithoutTime = FeatureThread(
id: "completed",
projectID: "project",
title: "Done task",
updatedAt: now.addingTimeInterval(-120),
state: .completed
)
let working = FeatureThread(
id: "working",
projectID: "project",
title: "Working task",
state: .working,
latestTurnCompletedAt: now.addingTimeInterval(-300)
)

#expect(completedWithoutTime.homeDoneDuration(at: now) == nil)
#expect(completedWithoutTime.homeDoneAccessibilityLabel(at: now) == nil)
#expect(completedWithoutTime.homeRowStatusLabel(at: now) == "2m")
#expect(
completedWithoutTime.homeRowAccessibilityStatus(rich: true, at: now)
== "Done. Updated 2 minutes ago"
)
#expect(completedWithoutTime.homeRowAccessibilityStatus(rich: false, at: now) == "Done")
#expect(working.homeDoneDuration(at: now) == nil)
#expect(working.homeRowStatusLabel(at: now) == "Working")
}

private func doneDuration(completedAtOffset: TimeInterval) -> String? {
completedThread(completedAtOffset: completedAtOffset).homeDoneDuration(at: now)
}

private func doneAccessibilityLabel(completedAtOffset: TimeInterval) -> String? {
completedThread(completedAtOffset: completedAtOffset)
.homeDoneAccessibilityLabel(at: now)
}

private func completedThread(completedAtOffset: TimeInterval) -> FeatureThread {
FeatureThread(
id: "completed",
projectID: "project",
title: "Done task",
state: .completed,
latestTurnCompletedAt: now.addingTimeInterval(completedAtOffset)
)
}

private func accessibilityDuration(startedAtOffset: TimeInterval) -> String {
HomeWorkingDuration.accessibility(
since: now.addingTimeInterval(startedAtOffset),
Expand Down
Loading