Skip to content

Design pr 1#372

Open
eonist wants to merge 98 commits into
mainfrom
fix/popover-scrollview-jobs-display-design-branch-1
Open

Design pr 1#372
eonist wants to merge 98 commits into
mainfrom
fix/popover-scrollview-jobs-display-design-branch-1

Conversation

@eonist
Copy link
Copy Markdown
Collaborator

@eonist eonist commented May 10, 2026

CodeAnt-AI Description

Redesign the popover to show live stats, clearer progress, and more history

What Changed

  • The popover now shows live CPU, memory, and disk usage in the header, with quick access to Settings, sign in, and Close
  • Action history shows more runs at once, supports loading 10 more items, and keeps child jobs visible under their parent action
  • Run and job rows now use pie-style progress indicators and clearer status labels, including partial progress for active work
  • Job, step, and settings views keep a fixed size while open to avoid popover jumps during updates
  • Local runner setup now blocks install paths outside the user’s home folder, and local runner scanning is limited to known install locations

Impact

✅ More action history visible at once
✅ Clearer CI progress at a glance
✅ Fewer popover jumps while data refreshes

🔄 Retrigger CodeAnt AI Review

Details

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

eonist and others added 30 commits May 8, 2026 12:28
🔴 Bugs:
- #1 actionStatusLabel → uppercase per spec (IN PROGRESS / SUCCESS / FAILED / CANCELED)
- #2 Child ↳ row status text → uppercase (IN PROGRESS / QUEUED)
- #3 [×] button → NSApplication.shared.hide(nil) instead of terminate(nil)
- #4 Child PieProgressView progress → real step completion fraction

🟡 Polish / Spec:
- #5 Fix ScrollView/LazyVStack/ForEach indentation alignment
- #6 Simplify Load more label to static "Load 10 more actions…"
- #7 Remove Text("Actions") section label
- #8 Runner rows → Button wrapper + chevron.right
- #9 Remove Text("Runners") section label
- #11 Add .onDisappear { systemStats.stop() }

📝 Docs:
- #10 Fix RunnerStoreObservable comment: "capped at 5" → "capped at 10"
- #5 PieProgressView partial state: replace .stroke ring arc with Path
  filled wedge so partial progress renders as a true pie slice (◔ ◑ ◕)
- #6 RunnerStoreObservable: add @mainactor for compile-time thread safety
- #8 Auth dot: route to onSelectSettings instead of signInWithGitHub()
- #9 Inline ↳ job rows: make passive (remove Button/onSelectJob/chevron)
- nesting: extract stepProgress(for:) helper to avoid function_level > 3
- function_body_length: split body into actionsSection + runnersSection
  @ViewBuilder sub-views to stay under 50-line warning threshold
- missing_docs: add /// to all new internal helpers
- actions cap: 5 → 50 (allows ~5 pages of "Load 10 more")
- jobs cap: 3 → 30
- Enables "Load 10 more actions…" button to surface older actions

Ref #305, #296
…llapse + RunnerStore runners (#296)

- RunnerStoreState: actions cap 5→50, jobs cap 3→30 — enables pagination (#305)
- PopoverMainView: remove LocalRunnerStore, drive runners from store.runners (#307)
- PopoverMainView: add @State expandedGroups per action group, expand by default for inProgress (#304)
- PopoverMainView: extract PopoverHeaderView, ActionRowView, InlineJobRowView, RunnersListView subview structs (#296)

Ref #296 #304 #305 #307
…r_is_empty, multiple_closures_with_trailing_closure)

- hasInlineJobs: filter{}.isEmpty → contains{} (contains_over_filter_is_empty)
- ActionRowView init: use explicit argument labels for onToggleExpand + onSelect closures
- Button(action:) in PopoverHeaderView: use label: parameter explicitly instead of trailing closure
- Button(action:) load-more: use label: parameter explicitly

Ref #296
- RunnersListView: disable no-op runner button until #307 detail is wired (removes misleading tappable chevron)
- PopoverMainView: reset visibleCount to 10 on store.actions.count change
- RunnerStoreObservable: fix doc comment ('capped at 10/3' refers to visibleCount in view, not store cap)
…sion (#311)

- Move RunnersListView above ActionsListView in body per spec (#296)
- Fix actionDotColor: use group.conclusion == \"success\" instead of
  runs.allSatisfy { $0.conclusion == \"success\" } which mis-labels
  partially-successful groups as red
Replace the static "BUSY" / "ONLINE" status label in RunnersListView
with runner.displayStatus, which already formats as
"active (CPU: x.x% MEM: x.x%)" or "idle (CPU: — MEM: —)".
The Runner model and RunnerMetrics are already populated by
RunnerStore.fetch() — this was the only missing wiring. Closes the
one remaining gap flagged in the overall verdict comment.
…#296)

- ActionsListView: add 'No more actions' muted label when all loaded (Phase 5 / #305)
- RunnersListView: filter to busy-only runners — spec says section hidden when idle (Phase 6 / #307)
- Gap 1 (#304): InlineJobRowView shows currentStepTitle + stepFraction instead of status label
- Gap 2 (#304): hasInlineJobs + InlineJobsView filter to in_progress only (drop queued)
- Gap 3 (#305): 'No more actions' label already present — confirmed correct
- Gap 4 (#307): RunnersListView shows CPU% + MEM% from runner.metrics; falls back to em-dash
- Bonus (#299): hide() confirmed correct for menu-bar app; tooltip updated to 'Close popover'

Closes #323
…on_body_length)

- Add /// docs to all undocumented private funcs in ActionRowView + InlineJobRowView
- Add scoped swiftlint:disable file_length (file is a single cohesive view decomposition)
- Add swiftlint:disable:next function_body_length on ActionRowView.body (SwiftUI body verbosity)
- SystemStatsViewModel: init() is no-op; serial samplingQueue; explicit start()/stop()
- PieProgressView: progress: Double? with nil indeterminate centre dot
- RelativeTimeFormatter: standalone testable enum with injectable relativeTo:
- ActionGroup.progressFraction + ActiveJob.progressFraction model extensions
- ActionRowView + InlineJobRowView: use progressFraction extensions
- InlineJobsView: @State cap=4 with load-more button (replaces hard prefix(5))
- PopoverMainView: .onChange observes full store.actions array (not just count)
- PopoverHeaderView: Sign in caption next to orange auth dot
- PopoverHeaderView: blockBar Unicode prefix on stat chips (spec #294/#296)

Ref: #363 #364 #365 (closed as duplicates)
…InlineJobRowView

- ActiveJob.progressFraction: Double? — model-layer extension matching
  ActionGroup.progressFraction pattern; nil = indeterminate (queued / no steps)
- InlineJobRowView: drop local jobProgressFraction(for:) helper, use
  job.progressFraction directly (same as ActionRowView uses actionGroup.progressFraction)

Ref: #366 minor observation
- RunnerStore.swift: add scoped file_length disable (411 lines, cohesive singleton)
- GitHub.swift: rewrite DispatchWorkItem(block:) as trailing closure to satisfy
  multiple_closures_with_trailing_closure rule
- PopoverMainView.swift: rewrite all Button(action:label:) as Button(action:) { }
  trailing-closure form to satisfy multiple_closures_with_trailing_closure
- RunnerStore.swift: remove superfluous file_length disable (rule fires on Logger.swift
  at lint position 13, not RunnerStore; disable was in wrong file)
- GitHub.swift: add scoped file_length disable (pre-existing, file is 411 lines)
- PopoverMainView.swift: rewrite all Button(action:) { } back to Button(action:label:) explicit
  form — SwiftLint multiple_closures_with_trailing_closure fires on Button because it has
  two closure params (action + label); explicit label: arg suppresses the rule
- PopoverMainView.swift: remove superfluous function_body_length disable on ActionRowView.body
…Store.swift

file_length is a whole-file rule — scoped disable/enable pairs don't suppress it.
Only a top-of-file disable (no matching enable) works correctly.
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐙 Octopus posted 6 inline findings. See the pinned Octopus summary comment for the full review.

status: payload.status,
conclusion: payload.conclusion,
startedAt: payload.startedAt.flatMap { iso.date(from: $0) },
createdAt: payload.createdAt.flatMap { iso.date(from: $0) },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 makeActiveJob passes pre-parsed String to JobStep but JobStep expects String — ISO parsing silently dropped

In the refactored makeActiveJob, step startedAt/completedAt are passed directly as the raw String? values from JobPayload (step.startedAt, step.completedAt) instead of being parsed through the ISO8601DateFormatter. JobStep.startedAt/completedAt are typed as Date? (visible in ActiveJob.swift), so the compiler would reject a String?. However, if JobStep was also changed to store String? in this PR, then PieProgressView and elapsed-time calculations that depend on Date arithmetic will silently receive nil or incorrect values whenever ISO parsing would have been needed. Verify that JobStep's stored type matches what is passed here — if JobStep stores Date?, the compiler would catch this, but if it was also changed to String? the date-dependent UI features break silently.

Suggested change
createdAt: payload.createdAt.flatMap { iso.date(from: $0) },
startedAt: step.startedAt.flatMap { iso.date(from: $0) },
completedAt: step.completedAt.flatMap { iso.date(from: $0) }
AI Fix Prompt
Fix the following High (Bug) issue in `Sources/RunnerBar/ActionGroup.swift` at lines 308-315:

Problem: In the refactored makeActiveJob, step startedAt/completedAt are passed directly as the raw String? values from JobPayload (step.startedAt, step.completedAt) instead of being parsed through the ISO8601DateFormatter. JobStep.startedAt/completedAt are typed as Date? (visible in ActiveJob.swift), so the compiler would reject a String?. However, if JobStep was also changed to store String? in this PR, then PieProgressView and elapsed-time calculations that depend on Date arithmetic will silently receive nil or incorrect values whenever ISO parsing would have been needed. Verify that JobStep's stored type matches what is passed here — if JobStep stores Date?, the compiler would catch this, but if it was also changed to String? the date-dependent UI features break silently.

Suggested fix:
startedAt: step.startedAt.flatMap { iso.date(from: $0) },
completedAt: step.completedAt.flatMap { iso.date(from: $0) }

/// Single action group row.
/// ⚠️ Inline ↳ job rows shown ONLY for jobs with status == "in_progress".
/// ❌ NEVER change filter to conclusion==nil — queued jobs must not appear inline per spec #178.
private struct ActionRowView: View {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 ActionRowView.actionDotColor and actionStatusColor use group.conclusion but ActionGroup has no .conclusion property

actionStatusLabel(for:) and actionStatusColor(for:) both switch on group.conclusion, but the ActionGroup struct visible in the codebase context has no .conclusion property — group status is derived from runs[].conclusion. If this compiles, it implies a .conclusion property was added elsewhere in this PR but is not visible in the diff; if not, this is a compile error. Verify ActionGroup exposes a .conclusion: String? that aggregates run conclusions, otherwise the switch will always fall through to 'default'.

Suggested change
private struct ActionRowView: View {
// In ActionGroup.swift, ensure this property exists:
var conclusion: String? {
guard groupStatus == .completed else { return nil }
if runs.allSatisfy({ $0.conclusion == "success" }) { return "success" }
if runs.contains(where: { $0.conclusion == "failure" }) { return "failure" }
if runs.allSatisfy({ $0.conclusion == "cancelled" }) { return "cancelled" }
return nil
}
AI Fix Prompt
Fix the following Medium (Bug) issue in `Sources/RunnerBar/PopoverMainView.swift` at lines 232-244:

Problem: actionStatusLabel(for:) and actionStatusColor(for:) both switch on group.conclusion, but the ActionGroup struct visible in the codebase context has no .conclusion property — group status is derived from runs[].conclusion. If this compiles, it implies a .conclusion property was added elsewhere in this PR but is not visible in the diff; if not, this is a compile error. Verify ActionGroup exposes a .conclusion: String? that aggregates run conclusions, otherwise the switch will always fall through to 'default'.

Suggested fix:
// In ActionGroup.swift, ensure this property exists:
var conclusion: String? {
    guard groupStatus == .completed else { return nil }
    if runs.allSatisfy({ $0.conclusion == "success" }) { return "success" }
    if runs.contains(where: { $0.conclusion == "failure" }) { return "failure" }
    if runs.allSatisfy({ $0.conclusion == "cancelled" }) { return "cancelled" }
    return nil
}

case ..<3_600: return "\(Int(seconds / 60))m ago"
case ..<172_800: return "\(Int(seconds / 3_600))h ago"
default: return "\(Int(seconds / 86_400))d ago"
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 RelativeTimeFormatter: 172_800-second boundary gives '48h ago' instead of '2d ago' for exactly 2 days

The switch uses ..<172_800 (48 hours) as the boundary for hours vs days. A value of exactly 172_800 seconds falls into the default 'days' case and returns '2d ago', which is correct. But values from 86_400 (24h) to 172_799 (47h59m) return hours — so '25h ago', '36h ago', '47h ago' are all valid outputs. Most relative-time formatters switch to days after 24h, not 48h. This is a product decision, but worth a deliberate comment if 48h is intentional.

Suggested change
}
// Change the days boundary to 86_400 (24 h) if day-level granularity
// is preferred after the first day:
case ..<86_400: return "\(Int(seconds / 3_600))h ago"
default: return "\(Int(seconds / 86_400))d ago"
AI Fix Prompt
Fix the following Medium (Logic Error) issue in `Sources/RunnerBar/RelativeTimeFormatter.swift` at lines 27-30:

Problem: The switch uses ..<172_800 (48 hours) as the boundary for hours vs days. A value of exactly 172_800 seconds falls into the default 'days' case and returns '2d ago', which is correct. But values from 86_400 (24h) to 172_799 (47h59m) return hours — so '25h ago', '36h ago', '47h ago' are all valid outputs. Most relative-time formatters switch to days after 24h, not 48h. This is a product decision, but worth a deliberate comment if 48h is intentional.

Suggested fix:
// Change the days boundary to 86_400 (24 h) if day-level granularity
// is preferred after the first day:
case ..<86_400:   return "\(Int(seconds / 3_600))h ago"
default:          return "\(Int(seconds / 86_400))d ago"

ForEach(jobs) { job in
InlineJobRowView(job: job)
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 InlineJobRowView hardcodes .yellow for PieProgressView color regardless of job conclusion

InlineJobRowView always passes color: .yellow to PieProgressView, which is correct because inProgressJobs is pre-filtered to status == 'in_progress'. However this is non-obvious to future readers. A brief comment or using a named constant would clarify intent.

Suggested change
}
// These rows are pre-filtered to in_progress only (see ActionRowView.inProgressJobs)
PieProgressView(
progress: job.progressFraction,
color: .yellow, // always in_progress at this call site
size: 7
)
AI Fix Prompt
Fix the following Nit (Style) issue in `Sources/RunnerBar/PopoverMainView.swift` at lines 338-343:

Problem: InlineJobRowView always passes color: .yellow to PieProgressView, which is correct because inProgressJobs is pre-filtered to status == 'in_progress'. However this is non-obvious to future readers. A brief comment or using a named constant would clarify intent.

Suggested fix:
// These rows are pre-filtered to in_progress only (see ActionRowView.inProgressJobs)
PieProgressView(
    progress: job.progressFraction,
    color: .yellow, // always in_progress at this call site
    size: 7
)

}
.buttonStyle(.plain)
Spacer() // ⚠️ load-bearing — pushes elapsed to right edge
Spacer()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Spacer() comments removed — load-bearing nature is no longer documented

The PR removes the '⚠️ load-bearing' annotation from Spacer() in ActionDetailView and ActionRowView. These Spacers are still functionally required (they push elapsed/chevron to the trailing edge). Removing the comment is fine under a 'less noise' policy, but the PopoverMainView regression guard explicitly calls out 'RULE 4: Job row HStack Spacer() is LOAD-BEARING', so the two files are now inconsistent in documentation style.

Suggested change
Spacer()
Spacer() // pushes trailing controls to right edge
AI Fix Prompt
Fix the following Nit (Style) issue in `Sources/RunnerBar/ActionDetailView.swift` at line 36:

Problem: The PR removes the '⚠️ load-bearing' annotation from Spacer() in ActionDetailView and ActionRowView. These Spacers are still functionally required (they push elapsed/chevron to the trailing edge). Removing the comment is fine under a 'less noise' policy, but the PopoverMainView regression guard explicitly calls out 'RULE 4: Job row HStack Spacer() is LOAD-BEARING', so the two files are now inconsistent in documentation style.

Suggested fix:
Spacer() // pushes trailing controls to right edge

/// and follows Link rel=next pagination to return all repos.
/// Returns an empty array on error or if unauthenticated.
/// Returns `owner/repo` strings for the authenticated user's repositories.
func fetchUserRepos() -> [String] {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 swiftlint:disable missing_docs removed from ActionGroup.swift but public-facing functions in GitHub.swift lost their doc-comments

The PR removes missing_docs from the ActionGroup.swift swiftlint disable list (good), implying docs will now be enforced there. Simultaneously, GitHub.swift removes several doc-comment blocks from internal functions (fetchUserOrgs, fetchUserRepos, fetchRegistrationToken). With missing_docs: warning for internal symbols, these removals will generate SwiftLint warnings on CI.

Suggested change
func fetchUserRepos() -> [String] {
/// Returns the login names of all organisations the authenticated user belongs to.
/// Calls `GET /user/orgs` with pagination. Returns an empty array on error.
func fetchUserOrgs() -> [String] {
AI Fix Prompt
Fix the following Nit (Style) issue in `Sources/RunnerBar/GitHub.swift` at lines 214-225:

Problem: The PR removes missing_docs from the ActionGroup.swift swiftlint disable list (good), implying docs will now be enforced there. Simultaneously, GitHub.swift removes several doc-comment blocks from internal functions (fetchUserOrgs, fetchUserRepos, fetchRegistrationToken). With missing_docs: warning for internal symbols, these removals will generate SwiftLint warnings on CI.

Suggested fix:
/// Returns the login names of all organisations the authenticated user belongs to.
/// Calls `GET /user/orgs` with pagination. Returns an empty array on error.
func fetchUserOrgs() -> [String] {

… navigation

onAppear fires synchronously during the first SwiftUI layout pass. localRunnerStore.refresh()
publishes @published changes which trigger a second layout pass before NSPopover has
finished anchoring — this causes a side jump even with a fixed-height frame.
Fix: dispatch refresh() async on main after a short delay so the popover is fully
anchored before any state update arrives. (ref #375 #377)
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐙 Octopus posted 4 inline findings. See the pinned Octopus summary comment for the full review.

status: payload.status,
conclusion: payload.conclusion,
startedAt: payload.startedAt.flatMap { iso.date(from: $0) },
createdAt: payload.createdAt.flatMap { iso.date(from: $0) },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 JobStep date fields silently changed from String? to an unverified type

In makeActiveJob(), the step construction now passes step.startedAt and step.completedAt directly (without the .flatMap { iso.date(from: $0) } conversion that was present for job-level fields). This implies JobStep.startedAt and JobStep.completedAt were changed from String? to Date? (or similar) elsewhere in ActiveJob.swift. If JobPayload's nested step fields are still String? (which is the typical Decodable shape for ISO 8601 timestamps from the GitHub API), these assignments will silently store raw strings in Date fields or fail to compile — one of which is a silent data corruption risk. The diff for JobStep in ActiveJob.swift does not show its stored-property types changing, making this a cross-file consistency concern that warrants explicit confirmation.

Suggested change
createdAt: payload.createdAt.flatMap { iso.date(from: $0) },
// Verify JobStep.startedAt / completedAt are Date? before removing the iso.date(from:) conversion.
// If they remain String?, restore:
startedAt: step.startedAt.flatMap { iso.date(from: $0) },
completedAt: step.completedAt.flatMap { iso.date(from: $0) }
AI Fix Prompt
Fix the following High (Bug) issue in `Sources/RunnerBar/ActionGroup.swift` at lines 310-315:

Problem: In makeActiveJob(), the step construction now passes step.startedAt and step.completedAt directly (without the .flatMap { iso.date(from: $0) } conversion that was present for job-level fields). This implies JobStep.startedAt and JobStep.completedAt were changed from String? to Date? (or similar) elsewhere in ActiveJob.swift. If JobPayload's nested step fields are still String? (which is the typical Decodable shape for ISO 8601 timestamps from the GitHub API), these assignments will silently store raw strings in Date fields or fail to compile — one of which is a silent data corruption risk. The diff for JobStep in ActiveJob.swift does not show its stored-property types changing, making this a cross-file consistency concern that warrants explicit confirmation.

Suggested fix:
// Verify JobStep.startedAt / completedAt are Date? before removing the iso.date(from:) conversion.
// If they remain String?, restore:
startedAt: step.startedAt.flatMap { iso.date(from: $0) },
completedAt: step.completedAt.flatMap { iso.date(from: $0) }

private var hostingController: NSHostingController<AnyView>?
private let observable = RunnerStoreObservable()
@MainActor private lazy var observable = RunnerStoreObservable()
private var savedNavState: NavState?
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 AppDelegate marked @unchecked Sendable without documented invariants

AppDelegate is now marked @unchecked Sendable, but its mutable properties (statusItem, popover, hostingController, savedNavState, popoverIsOpen) are not documented as being accessed exclusively from the main thread, and the class is not annotated @mainactor. The view factory methods are individually @mainactor, but togglePopover() is @mainactor @objc while popoverDidClose() and the RunnerStore.onChange closure (which now dispatches reload() via DispatchQueue.main.async) are not explicitly @mainactor. This is a fragile contract: future contributors can add background-thread access without a compile-time warning. @unchecked Sendable suppresses exactly the diagnostic that would catch this.

Suggested change
private var savedNavState: NavState?
// Option A: annotate the class itself
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
// removes need for @unchecked Sendable and per-method @MainActor
}
// Option B: if full @MainActor is not feasible, add a comment block:
// THREAD SAFETY: all stored properties are accessed exclusively on the main thread.
// @unchecked Sendable is safe only under this invariant.
AI Fix Prompt
Fix the following High (Race Condition) issue in `Sources/RunnerBar/AppDelegate.swift` at line 54:

Problem: AppDelegate is now marked @unchecked Sendable, but its mutable properties (statusItem, popover, hostingController, savedNavState, popoverIsOpen) are not documented as being accessed exclusively from the main thread, and the class is not annotated @MainActor. The view factory methods are individually @MainActor, but togglePopover() is @MainActor @objc while popoverDidClose() and the RunnerStore.onChange closure (which now dispatches reload() via DispatchQueue.main.async) are not explicitly @MainActor. This is a fragile contract: future contributors can add background-thread access without a compile-time warning. @unchecked Sendable suppresses exactly the diagnostic that would catch this.

Suggested fix:
// Option A: annotate the class itself
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
    // removes need for @unchecked Sendable and per-method @MainActor
}

// Option B: if full @MainActor is not feasible, add a comment block:
// THREAD SAFETY: all stored properties are accessed exclusively on the main thread.
// @unchecked Sendable is safe only under this invariant.

case ..<60: return "just now"
case ..<3_600: return "\(Int(seconds / 60))m ago"
case ..<172_800: return "\(Int(seconds / 3_600))h ago"
default: return "\(Int(seconds / 86_400))d ago"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 RelativeTimeFormatter 172_800s boundary skips the '1 day ago' case

The switch ladder uses 172_800 (48 hours) as the upper bound for the hours bucket, meaning events between 24h and 48h are rendered as '24h ago', '25h ago', etc. instead of '1d ago', '2d ago'. Most users expect '1d ago' for anything over 24 hours. The 48-hour threshold was likely chosen to avoid '1d ago' appearing too soon, but the current boundary means the hours label can display values >= 24 which looks odd ('36h ago'). Consider 86_400 (24h) as the hours/days boundary.

Suggested change
default: return "\(Int(seconds / 86_400))d ago"
switch seconds {
case ..<60: return "just now"
case ..<3_600: return "\(Int(seconds / 60))m ago"
case ..<86_400: return "\(Int(seconds / 3_600))h ago"
default: return "\(Int(seconds / 86_400))d ago"
}
AI Fix Prompt
Fix the following Medium (Logic Error) issue in `Sources/RunnerBar/RelativeTimeFormatter.swift` at lines 27-29:

Problem: The switch ladder uses 172_800 (48 hours) as the upper bound for the hours bucket, meaning events between 24h and 48h are rendered as '24h ago', '25h ago', etc. instead of '1d ago', '2d ago'. Most users expect '1d ago' for anything over 24 hours. The 48-hour threshold was likely chosen to avoid '1d ago' appearing too soon, but the current boundary means the hours label can display values >= 24 which looks odd ('36h ago'). Consider 86_400 (24h) as the hours/days boundary.

Suggested fix:
switch seconds {
case ..<60:      return "just now"
case ..<3_600:   return "\(Int(seconds / 60))m ago"
case ..<86_400:  return "\(Int(seconds / 3_600))h ago"
default:         return "\(Int(seconds / 86_400))d ago"
}


/// Rebuilds the cache keyed by head_sha for `fetchActionGroups`.
/// Merges richer step data from `jobCache` into group jobs where available.
func enrichGroupJobs(_ jobs: [ActiveJob], jobCache: [Int: ActiveJob]) -> [ActiveJob] {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 enrichGroupJobs() is public but has no doc comment after missing_docs was re-enabled

ActionGroup.swift removed missing_docs from the swiftlint:disable list, meaning doc comments are now enforced. enrichGroupJobs() is func (internal visibility) and the swiftlint config warns on internal. It currently has no /// doc comment, which will trigger a SwiftLint warning under the missing_docs opt-in rule.

Suggested change
func enrichGroupJobs(_ jobs: [ActiveJob], jobCache: [Int: ActiveJob]) -> [ActiveJob] {
/// Merges richer step data from `jobCache` into `jobs` where available.
/// Replaces step arrays for jobs that are in-progress or have no steps yet.
/// - Parameters:
/// - jobs: The flat job list to enrich.
/// - jobCache: The completed-job cache keyed by job ID.
/// - Returns: A new array with step data backfilled where possible.
func enrichGroupJobs(_ jobs: [ActiveJob], jobCache: [Int: ActiveJob]) -> [ActiveJob] {
AI Fix Prompt
Fix the following Nit (Style) issue in `Sources/RunnerBar/RunnerStoreState.swift` at lines 161-163:

Problem: ActionGroup.swift removed missing_docs from the swiftlint:disable list, meaning doc comments are now enforced. enrichGroupJobs() is func (internal visibility) and the swiftlint config warns on internal. It currently has no /// doc comment, which will trigger a SwiftLint warning under the missing_docs opt-in rule.

Suggested fix:
/// Merges richer step data from `jobCache` into `jobs` where available.
/// Replaces step arrays for jobs that are in-progress or have no steps yet.
/// - Parameters:
///   - jobs: The flat job list to enrich.
///   - jobCache: The completed-job cache keyed by job ID.
/// - Returns: A new array with step data backfilled where possible.
func enrichGroupJobs(_ jobs: [ActiveJob], jobCache: [Int: ActiveJob]) -> [ActiveJob] {

eonist added 2 commits May 11, 2026 20:51
…chor jumps

The root cause of position jumps in Architecture 1 (.preferredContentSize):
SystemStatsViewModel fires @published updates every ~1s via a Timer.
Each update triggers a SwiftUI layout pass → new preferredContentSize →
NSPopover re-anchors the window → left/position jump.

The existing popoverIsOpen guard only blocks store.reload().
Nothing was blocking systemStats from pushing layout passes while shown.

Fix:
- Add `var isPopoverOpen: Bool = false` to PopoverMainView
- On .onChange(of: isPopoverOpen): stop systemStats when open, start when closed
- AppDelegate passes `isPopoverOpen: popoverIsOpen` when building mainView()

No architecture changes. No sizing changes. sizingOptions = .preferredContentSize
remains correct. The popover now opens, loads, and stays anchored.

Regression guards: ref #52 #54 #57 #296 #375 #376 #377
…n drill-down view roots

maxHeight:.infinity on a view root with sizingOptions=.preferredContentSize causes
NSHostingController to report the full uncapped ScrollView content height as
preferredContentSize.height on every state change (tick, log load, step update).
NSPopover sees a new contentSize → re-anchors → side jump on every navigate() call.

Fix: replace .frame(idealWidth:420, maxWidth:.infinity, maxHeight:.infinity) with
.frame(minWidth:420, idealWidth:420, maxWidth:420, minHeight:480, idealHeight:480, maxHeight:480)
on ActionDetailView, JobDetailView, and StepLogView.

480pt provides a stable fixed height for all three drill-down views:
- preferredContentSize.height = 480 always → NSPopover never re-anchors
- ScrollView inside each view clips and scrolls content internally
- Width = 420 always (same as PopoverMainView and SettingsView) → no horizontal jump

❌ NEVER revert maxHeight to .infinity on these views.
❌ NEVER use .fixedSize inside the inner ScrollView.
(ref #49 #51 #52 #53 #54 #57 #321 #370 #375 #376 #377)
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐙 Octopus posted 5 inline findings. See the pinned Octopus summary comment for the full review.

/// Actions path level 2a: job list for a commit/PR group.
case actionDetail(ActionGroup)
/// Actions path level 3a: step list for a job reached via an action group.
case actionJobDetail(ActiveJob, ActionGroup)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 AppDelegate marked @unchecked Sendable without enforcing the contract

AppDelegate adopts @unchecked Sendable but its mutable state (statusItem, popover, hostingController, savedNavState, popoverIsOpen) is accessed from @MainActor-annotated methods AND from @objc callbacks that run on the main thread by convention — but the compiler no longer enforces this. The RunnerStore.shared.onChange closure (set in applicationDidFinishLaunching) reads self.popoverIsOpen and calls self.observable.reload() without a DispatchQueue.main.async guard in the closure body itself for the popoverIsOpen read, even though a DispatchQueue.main.async wrapper is added for reload(). If onChange is ever triggered from a background thread in RunnerStore, popoverIsOpen is read without synchronization. Prefer confining AppDelegate to @MainActor explicitly rather than @unchecked Sendable.

Suggested change
case actionJobDetail(ActiveJob, ActionGroup)
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
AI Fix Prompt
Fix the following High (Bug) issue in `Sources/RunnerBar/AppDelegate.swift` at line 55:

Problem: AppDelegate adopts `@unchecked Sendable` but its mutable state (`statusItem`, `popover`, `hostingController`, `savedNavState`, `popoverIsOpen`) is accessed from `@MainActor`-annotated methods AND from `@objc` callbacks that run on the main thread by convention — but the compiler no longer enforces this. The `RunnerStore.shared.onChange` closure (set in `applicationDidFinishLaunching`) reads `self.popoverIsOpen` and calls `self.observable.reload()` without a `DispatchQueue.main.async` guard in the closure body itself for the `popoverIsOpen` read, even though a `DispatchQueue.main.async` wrapper is added for `reload()`. If `onChange` is ever triggered from a background thread in `RunnerStore`, `popoverIsOpen` is read without synchronization. Prefer confining AppDelegate to `@MainActor` explicitly rather than `@unchecked Sendable`.

Suggested fix:
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {

case ..<3_600: return "\(Int(seconds / 60))m ago"
case ..<172_800: return "\(Int(seconds / 3_600))h ago"
default: return "\(Int(seconds / 86_400))d ago"
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 RelativeTimeFormatter 172_800s boundary skips 1-day display

The switch ranges are: <60 → "just now", <3_600 → "Xm ago", <172_800 → "Xh ago", else → "Xd ago". The 172_800 threshold is 48 hours, so anything from 25h to 47h returns e.g. "25h ago" or "47h ago" rather than "1d ago" or "2d ago". This is a UX inconsistency — most relative formatters switch to days at 24h. The boundary should be 86_400 (24h) to switch to days after one day.

Suggested change
}
case ..<60: return "just now"
case ..<3_600: return "\(Int(seconds / 60))m ago"
case ..<86_400: return "\(Int(seconds / 3_600))h ago"
default: return "\(Int(seconds / 86_400))d ago"
AI Fix Prompt
Fix the following Medium (Logic Error) issue in `Sources/RunnerBar/RelativeTimeFormatter.swift` at lines 27-30:

Problem: The `switch` ranges are: `<60` → "just now", `<3_600` → "Xm ago", `<172_800` → "Xh ago", else → "Xd ago". The `172_800` threshold is 48 hours, so anything from 25h to 47h returns e.g. "25h ago" or "47h ago" rather than "1d ago" or "2d ago". This is a UX inconsistency — most relative formatters switch to days at 24h. The boundary should be `86_400` (24h) to switch to days after one day.

Suggested fix:
case ..<60:      return "just now"
case ..<3_600:   return "\(Int(seconds / 60))m ago"
case ..<86_400:  return "\(Int(seconds / 3_600))h ago"
default:         return "\(Int(seconds / 86_400))d ago"

lhs.id == rhs.id
&& lhs.isDimmed == rhs.isDimmed
&& lhs.jobs == rhs.jobs
&& lhs.runs.map({ $0.id }) == rhs.runs.map({ $0.id })
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 ActionGroup.progressFraction uses groupStatus but ActionGroup.Equatable ignores it

progressFraction delegates to groupStatus which is derived from runs[].status. However, ActionGroup.Equatable only compares id, isDimmed, jobs, and run IDs — not runs[].status or runs[].conclusion. This means if a run transitions from in_progress to completed without a change in job list or run IDs, == returns true and SwiftUI may skip re-rendering the PieProgressView even though progressFraction has changed. Consider including runs.map({ $0.status }) in the equality check.

Suggested change
&& lhs.runs.map({ $0.id }) == rhs.runs.map({ $0.id })
static func == (lhs: ActionGroup, rhs: ActionGroup) -> Bool {
lhs.id == rhs.id
&& lhs.isDimmed == rhs.isDimmed
&& lhs.jobs == rhs.jobs
&& lhs.runs.map({ $0.id }) == rhs.runs.map({ $0.id })
&& lhs.runs.map({ $0.status }) == rhs.runs.map({ $0.status })
&& lhs.runs.map({ $0.conclusion }) == rhs.runs.map({ $0.conclusion })
}
AI Fix Prompt
Fix the following Medium (Logic Error) issue in `Sources/RunnerBar/ActionGroup.swift` at lines 172-183:

Problem: `progressFraction` delegates to `groupStatus` which is derived from `runs[].status`. However, `ActionGroup.Equatable` only compares `id`, `isDimmed`, `jobs`, and `run IDs` — not `runs[].status` or `runs[].conclusion`. This means if a run transitions from `in_progress` to `completed` without a change in job list or run IDs, `==` returns `true` and SwiftUI may skip re-rendering the `PieProgressView` even though `progressFraction` has changed. Consider including `runs.map({ $0.status })` in the equality check.

Suggested fix:
static func == (lhs: ActionGroup, rhs: ActionGroup) -> Bool {
    lhs.id == rhs.id
        && lhs.isDimmed == rhs.isDimmed
        && lhs.jobs == rhs.jobs
        && lhs.runs.map({ $0.id }) == rhs.runs.map({ $0.id })
        && lhs.runs.map({ $0.status }) == rhs.runs.map({ $0.status })
        && lhs.runs.map({ $0.conclusion }) == rhs.runs.map({ $0.conclusion })
}

/// Root level: PopoverMainView.
case main
/// Jobs path level 2: step list for a job.
case jobDetail(ActiveJob)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 "DO NOT REMOVE THIS COMMENT" pragma-style directives are brittle

The comment block contains multiple sentences of the form "If you are an agent or human, DO NOT REMOVE THIS COMMENT, YOU ARE NOT ALLOWED UNDER ANY CIRCUMSTANCE." This pattern appears three times in the diff (AppDelegate.swift, PopoverMainView.swift twice). While the intent — preventing regression — is good, this style is more emphatic than informative. The technical rationale immediately before these sentences is excellent; the directive itself adds noise and may cause developers to distrust the preceding explanation. Consider replacing the mandate with a test or a CI lint check that verifies the property exists, or at minimum reformulate as "⚠️ Removing this causes regression #375/#376/#377 — see status-bar-app-position-warning.md §4."

Suggested change
case jobDetail(ActiveJob)
// ⚠️ Removing `isPopoverOpen` causes position-jump regression — see #375 #376 #377
// and status-bar-app-position-warning.md §4 (Architecture 1).
AI Fix Prompt
Fix the following Low (Style) issue in `Sources/RunnerBar/AppDelegate.swift` at lines 48-52:

Problem: The comment block contains multiple sentences of the form "If you are an agent or human, DO NOT REMOVE THIS COMMENT, YOU ARE NOT ALLOWED UNDER ANY CIRCUMSTANCE." This pattern appears three times in the diff (AppDelegate.swift, PopoverMainView.swift twice). While the intent — preventing regression — is good, this style is more emphatic than informative. The technical rationale immediately before these sentences is excellent; the directive itself adds noise and may cause developers to distrust the preceding explanation. Consider replacing the mandate with a test or a CI lint check that verifies the property exists, or at minimum reformulate as "⚠️ Removing this causes regression #375/#376/#377 — see status-bar-app-position-warning.md §4."

Suggested fix:
// ⚠️ Removing `isPopoverOpen` causes position-jump regression — see #375 #376 #377
// and status-bar-app-position-warning.md §4 (Architecture 1).

let workflowRuns: [RunPayload]
/// Maps `workflow_runs` JSON key to `workflowRuns`.
enum CodingKeys: String, CodingKey { case workflowRuns = "workflow_runs" }
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 ActionRunsResponse and PRRef are now internal (not private) — widens API surface

In the original ActionGroup.swift these types were private. Extracting them to a new file without marking them internal (the default) or private makes them visible to the entire module. RunPayload, HeadCommit, and PRRef are raw API decoding types with no business value outside fetchActionGroups. Marking them private in the new file is not possible since they're module-level, but they can be marked internal explicitly or — better — moved into an enum namespace (e.g. enum GitHub.API {}) to signal their limited purpose. The SwiftLint missing_docs rule is now unenforced for this file because ActionGroup.swift removed missing_docs from the swiftlint:disable list but ActionRunsResponse.swift doesn't have a disable directive, so the rule will fire on these internal types.

Suggested change
}
// At minimum, add a file-level disable to silence the enforced missing_docs rule,
// or add doc comments to each declaration.
// swiftlint:disable:next missing_docs
struct RunPayload: Codable {
AI Fix Prompt
Fix the following Low (Architecture) issue in `Sources/RunnerBar/ActionRunsResponse.swift` at lines 6-11:

Problem: In the original `ActionGroup.swift` these types were `private`. Extracting them to a new file without marking them `internal` (the default) or `private` makes them visible to the entire module. `RunPayload`, `HeadCommit`, and `PRRef` are raw API decoding types with no business value outside `fetchActionGroups`. Marking them `private` in the new file is not possible since they're module-level, but they can be marked `internal` explicitly or — better — moved into an `enum` namespace (e.g. `enum GitHub.API {}`) to signal their limited purpose. The SwiftLint `missing_docs` rule is now unenforced for this file because `ActionGroup.swift` removed `missing_docs` from the swiftlint:disable list but `ActionRunsResponse.swift` doesn't have a disable directive, so the rule will fire on these `internal` types.

Suggested fix:
// At minimum, add a file-level disable to silence the enforced missing_docs rule,
// or add doc comments to each declaration.
// swiftlint:disable:next missing_docs
struct RunPayload: Codable {

…receiving false on first render

In openPopover(), popoverIsOpen is set to true then mainView() is called with
isPopoverOpen: popoverIsOpen. But Swift captures the parameter value at call-site,
and at that exact moment the Bool literal `popoverIsOpen` evaluates to true — HOWEVER
the view is constructed with whatever value popoverIsOpen held at the time the closure
capturing it ran. The safe fix is to pass the literal `true` directly so the view
always receives isPopoverOpen:true on first render inside openPopover().

Without this, .onChange(of: isPopoverOpen) fires with open=false on the first render
→ systemStats.start() is called → @published timer fires every ~1s while popover is
open → SwiftUI layout passes → preferredContentSize.height changes → NSPopover
re-anchors → side jump.

refs: #375 #376 #377"
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 11, 2026

CodeAnt AI is running Incremental review

@codeant-ai codeant-ai Bot added size:XXL This PR changes 1000+ lines, ignoring generated files and removed size:XL This PR changes 500-999 lines, ignoring generated files labels May 11, 2026
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 11, 2026

CodeAnt AI Incremental review completed.

eonist added 2 commits May 11, 2026 22:55
…k timers

ROOT CAUSE OF REMAINING JUMPS:
1. JobDetailView had .frame(minHeight:480, idealHeight:480, maxHeight:480) — fixed.
   Its tick Timer fired every 1s — SwiftUI re-render — on navigate back to main,
   preferredContentSize transitioned from 480→main-height — NSPopover re-anchored — jump.
2. SettingsView had .frame(minHeight:560, ..., maxHeight:560) — fixed, not dynamic.
   localRunnerStore @published updates fired while popover open — potential jumps.

FIX FOR ALL VIEWS:
- Root frame: .frame(idealWidth: 420) ONLY — no height constraints.
  preferredContentSize.width = 420 always (stable), height = natural content height.
- JobDetailView: tick Timer gated — only runs while popover is open (isPopoverOpen prop).
  Stops on .onDisappear as safety net. No timer — no periodic re-renders — no height changes.
- SettingsView: .frame(idealWidth:420, idealHeight:cappedHeight) — caps unbounded
  ScrollView ideal height. localRunnerStore.refresh() deferred 150ms (unchanged).
  Fixed min/max height removed — popover height now equals cappedHeight dynamically.
- ActionDetailView: fixed frame removed, idealWidth:420 only.
- StepLogView: fixed frame removed, idealWidth:420 only.

refs: #375 #376 #377 #52 #54 #57
… fill frame — eliminates all side-jump regressions (ref #375 #376 #377)
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 11, 2026

CodeAnt AI is running Incremental review

@codeant-ai codeant-ai Bot added size:XXL This PR changes 1000+ lines, ignoring generated files and removed size:XXL This PR changes 1000+ lines, ignoring generated files labels May 11, 2026
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 11, 2026

CodeAnt AI Incremental review completed.

eonist added 4 commits May 11, 2026 23:56
…Popover

- Add HeightReporter.swift: overlay GeometryReader PreferenceKey reports
  rendered height to AppDelegate via HeightReceiver protocol
- AppDelegate: conform to HeightReceiver, call popover.contentSize in
  didUpdateHeight() so panel resizes live as content changes
- Wire .reportHeight(to: self) on all view factories
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 11, 2026

CodeAnt AI is running Incremental review

@codeant-ai codeant-ai Bot added size:XXL This PR changes 1000+ lines, ignoring generated files and removed size:XXL This PR changes 1000+ lines, ignoring generated files labels May 11, 2026
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 11, 2026

CodeAnt AI Incremental review completed.

eonist added 3 commits May 12, 2026 01:56
…re-anchor on contentSize change

- Add PanelChrome.swift: NSPanel subclass with arrow chrome, vibrancy,
  outside-click dismissal, and in-place updateHeight() (no re-anchor possible)
- AppDelegate: remove NSPopover/NSPopoverDelegate, add PanelChrome;
  conform to HeightReceiver — didUpdateHeight() calls panel.updateHeight();
  preserve all NavState, ActionDetailView, SettingsView, CPU guard, and
  regression guard comments intact

refs #52 #54 #57 #59 #296 #375 #376 #377
@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐙 Octopus posted 2 inline findings. See the pinned Octopus summary comment for the full review.

}
return String(run.headSha.prefix(7))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 ActionGroup.Equatable ignores firstJobStartedAt / lastJobCompletedAt — stale elapsed display possible

The Equatable implementation compares id, isDimmed, jobs, and runs.map(.id). It does not include firstJobStartedAt or lastJobCompletedAt. If a group's start/end timestamps change (e.g. the first job starts after the group was initially enqueued) but the job list and run IDs are otherwise the same, SwiftUI's onChange(of: store.actions) will not fire and the elapsed/startedAgo display will not update. The timestamps are derived from job data, so they will typically change together with jobs — but the Equatable contract is weaker than intended.

Suggested change
static func == (lhs: ActionGroup, rhs: ActionGroup) -> Bool {
lhs.id == rhs.id
&& lhs.isDimmed == rhs.isDimmed
&& lhs.jobs == rhs.jobs
&& lhs.runs.map({ $0.id }) == rhs.runs.map({ $0.id })
&& lhs.firstJobStartedAt == rhs.firstJobStartedAt
&& lhs.lastJobCompletedAt == rhs.lastJobCompletedAt
}
AI Fix Prompt
Fix the following Medium (Logic Error) issue in `Sources/RunnerBar/ActionGroup.swift` at lines 180-186:

Problem: The Equatable implementation compares id, isDimmed, jobs, and runs.map(\.id). It does not include firstJobStartedAt or lastJobCompletedAt. If a group's start/end timestamps change (e.g. the first job starts after the group was initially enqueued) but the job list and run IDs are otherwise the same, SwiftUI's onChange(of: store.actions) will not fire and the elapsed/startedAgo display will not update. The timestamps are derived from job data, so they will typically change together with jobs — but the Equatable contract is weaker than intended.

Suggested fix:
static func == (lhs: ActionGroup, rhs: ActionGroup) -> Bool {
    lhs.id == rhs.id
        && lhs.isDimmed == rhs.isDimmed
        && lhs.jobs == rhs.jobs
        && lhs.runs.map({ $0.id }) == rhs.runs.map({ $0.id })
        && lhs.firstJobStartedAt == rhs.firstJobStartedAt
        && lhs.lastJobCompletedAt == rhs.lastJobCompletedAt
}

case ..<3_600: return "\(Int(seconds / 60))m ago"
case ..<172_800: return "\(Int(seconds / 3_600))h ago"
default: return "\(Int(seconds / 86_400))d ago"
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 RelativeTimeFormatter '3d ago' threshold uses 172_800 (48h) not a true day boundary

The switch case ..<172_800 (48 hours) is used as the boundary between hours and days. The doc comment says '2 hours ago → 2h ago / 3 days ago → 3d ago', implying the intent is a 24-hour (86_400s) boundary. With the current threshold, a run from 47 hours ago shows '47h ago' rather than '1d ago'. This is a minor UX inconsistency — recommend aligning the threshold with the documented examples.

Suggested change
}
case ..<86_400: return "\(Int(seconds / 3_600))h ago"
default: return "\(Int(seconds / 86_400))d ago"
AI Fix Prompt
Fix the following Low (Logic Error) issue in `Sources/RunnerBar/RelativeTimeFormatter.swift` at lines 27-30:

Problem: The switch case ..<172_800 (48 hours) is used as the boundary between hours and days. The doc comment says '2 hours ago → 2h ago / 3 days ago → 3d ago', implying the intent is a 24-hour (86_400s) boundary. With the current threshold, a run from 47 hours ago shows '47h ago' rather than '1d ago'. This is a minor UX inconsistency — recommend aligning the threshold with the documented examples.

Suggested fix:
case ..<86_400:   return "\(Int(seconds / 3_600))h ago"
default:          return "\(Int(seconds / 86_400))d ago"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant