Skip to content

feat: PopoverMainView redesign (#296)#311

Merged
eonist merged 80 commits into
mainfrom
feature/296-popover-main-view-redesign
May 10, 2026
Merged

feat: PopoverMainView redesign (#296)#311
eonist merged 80 commits into
mainfrom
feature/296-popover-main-view-redesign

Conversation

@eonist
Copy link
Copy Markdown
Collaborator

@eonist eonist commented May 8, 2026

User description

Summary

Implements the full PopoverMainView redesign as specified in #296. #178 (comment) and #178 (comment)

Phases

Closes

Closes #296
Closes #297
Closes #299
Closes #302
Closes #304
Closes #305
Closes #307

Regression Guards

Notes


CodeAnt-AI Description

Redesign the popover with live system stats, richer action history, and inline job details

What Changed

  • The popover now shows CPU, memory, and disk usage in a fixed header with Settings, sign-in, and close controls always visible
  • Recent actions are shown in a longer scrollable list with a “Load 10 more actions…” control, and active workflow runs now expand inline to show running jobs under each action
  • Status indicators now use pie-style progress dots and clearer uppercase labels, with relative “started” times added to action rows
  • The local runners section now only appears for active runners and shows live CPU and memory usage
  • The runner registration sheet now rejects install paths outside the user’s home folder and shows clearer loading and error messages

Impact

✅ Faster scan of recent workflow activity
✅ Clearer live status for running jobs and actions
✅ Safer runner registration paths

🔄 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.

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 8, 2026

CodeAnt AI is reviewing your PR.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 8, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a reusable SwiftUI PieProgressView, a RunnerStoreObservable state bridge, moves system stats into PopoverMainView’s header (gear + close icons), removes the standalone System/footer quit UI, extracts SystemStatsView.statsContent, adds ActionGroup.startedAgo, and rewrites the actions list with pagination and inline job sub-rows.

Changes

UI Components and State Bridge for PopoverMainView Redesign Phase 1

Layer / File(s) Summary
ActionGroup.startedAgo (data)
Sources/RunnerBar/ActionGroup.swift
Adds var startedAgo: String returning a short "time ago" string from firstJobStartedAt or createdAt, or when unavailable.
State Bridge for Observation
Sources/RunnerBar/RunnerStoreObservable.swift
New @MainActor ObservableObject mirrors RunnerStore.shared into @Published properties: actions, jobs, runners, isRateLimited; reload() copies current store state inside withAnimation(nil).
SystemStatsView Refactor
Sources/RunnerBar/SystemStatsView.swift
Extracted statsContent: some View; body now wraps statsContent and applies .padding(.horizontal, 12).padding(.vertical, 4) while preserving .lineLimit(1).
PieProgressView Component
Sources/RunnerBar/PieProgressView.swift
New SwiftUI View renders a circular progress indicator with three states: outlined ring (progress == 0), filled pie wedge via arc from 12 o'clock (0 < progress < 1), or solid filled circle (progress >= 1). Configurable progress: Double, color: Color, and size: CGFloat (default 8).
Popover Header
Sources/RunnerBar/PopoverMainView.swift
Header replaced: embeds SystemStatsView(stats:).statsContent inline, shows an orange unauthenticated dot when applicable, and exposes icon-only gear and xmark buttons; version label removed and bottom Quit button removed.
Actions list & Inline Jobs
Sources/RunnerBar/PopoverMainView.swift
Rewrites actions area to a scrollable LazyVStack with visibleCount pagination, redesigns action rows to include pie progress, SHA, title, startedAgo, elapsed, job fraction, and uppercase status; in-progress groups inline-display up to 3 sub-rows for active jobs.
Helpers / Status mapping
Sources/RunnerBar/PopoverMainView.swift
Adds actionStatusLabel(for:) and actionStatusColor(for:), runnerDotColor(for:), updates jobStatusLabel/conclusionLabel to uppercase, and changes GitHub sign-in to route to settings.

Sequence Diagram(s)

sequenceDiagram
  participant UI as PopoverMainView
  participant Stats as SystemStatsView
  participant Obs as RunnerStoreObservable
  participant Store as RunnerStore.shared
  UI->>Obs: subscribes to `@Published` actions/runners/jobs
  Obs->>Store: read current state (reload)
  Store-->>Obs: returns actions/jobs/runners/isRateLimited
  Obs-->>UI: publishes updates
  UI->>Stats: embed statsContent for header
  UI->>UI: render actions list (PieProgressView, inline ↳ jobs, runners)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

  • #297: Implements the new PieProgressView component as specified.
  • #299: Moves SystemStatsView content into the header and removes the standalone system row.
  • #302: Action row redesign (started/time/status text and pie-dot integration) aligns with these changes.

Possibly related PRs

Suggested labels

size:XXL

Poem

🥕 I drew a tiny pie with care,
a dot that fills and arcs to share,
I listen to the store's soft hum,
I tuck the stats where headers drum,
hop in — the popover bakes bright flair!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The implementation addresses all six phases of the redesign (#297 PieProgressView, #299 header, #302 action rows, #304 inline jobs, #305 pagination, #307 runners), but critical bugs exist: status labels use title-case not uppercase, the close button quits instead of hiding, child job progress is hardcoded, and lifecycle management is incomplete. Fix critical issues: make status labels uppercase (IN PROGRESS, SUCCESS, FAILED, CANCELED), change × button to hide app, compute child PieProgressView progress from actual step completion, and add .onDisappear to stop systemStats updates.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'feat: PopoverMainView redesign (#296)' accurately reflects the main change—a comprehensive redesign of the PopoverMainView component across six phases as described in the linked issue.
Out of Scope Changes check ✅ Passed All changes align with the six-phase redesign objectives. The addition of RunnerStoreObservable, ActionGroup.startedAgo, SystemStatsView.statsContent, and PieProgressView are all explicitly required by the linked issues.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/296-popover-main-view-redesign

Comment @coderabbitai help to get the list of available commands and usage tips.

@qodo-code-review
Copy link
Copy Markdown

Review Summary by Qodo

Add PieProgressView and RunnerStoreObservable for redesign

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add PieProgressView component for circular progress indicators
• Implement RunnerStoreObservable bridge for SwiftUI state management
• Enable live runner/job/action data binding to views
Diagram
flowchart LR
  RS["RunnerStore.shared"]
  RSO["RunnerStoreObservable"]
  PV["PieProgressView"]
  Views["SwiftUI Views"]
  
  RS -- "reload()" --> RSO
  RSO -- "@Published properties" --> Views
  PV -- "progress indicator" --> Views
Loading

Grey Divider

File Changes

1. Sources/RunnerBar/PieProgressView.swift ✨ Enhancement +36/-0

Add PieProgressView circular progress component

• New circular progress indicator component with three visual states
• Renders empty outline, partial arc, or solid fill based on progress value
• Supports customizable size and status-driven color
• Used in action rows (size 8) and child job rows (size 7)

Sources/RunnerBar/PieProgressView.swift


2. Sources/RunnerBar/RunnerStoreObservable.swift ✨ Enhancement +35/-0

Add RunnerStoreObservable for SwiftUI state binding

• New ObservableObject bridge for RunnerStore state synchronization
• Exposes @Published properties for actions, jobs, runners, and rate limit status
• Implements reload() method to sync store state on main thread
• Suppresses SwiftUI animations during state updates to prevent layout thrashing

Sources/RunnerBar/RunnerStoreObservable.swift


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 8, 2026

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Remediation recommended

1. Pie dot has halo 🐞 Bug ≡ Correctness
Description
PieProgressView always draws the outline stroke even when progress >= 1.0, so the “solid filled
circle (●)” state can still show an outer ring/halo. This contradicts the documented visual behavior
and can make completed items look not-fully-solid.
Code

Sources/RunnerBar/PieProgressView.swift[R23-31]

+            Circle()
+                .stroke(color.opacity(0.25), lineWidth: size * 0.25)
+            if progress >= 1.0 {
+                Circle().fill(color)
+            } else if progress > 0 {
+                Circle()
+                    .trim(from: 0, to: CGFloat(progress))
+                    .rotation(.degrees(-90))
+                    .stroke(color, style: StrokeStyle(lineWidth: size * 0.25, lineCap: .round))
Evidence
The view unconditionally draws a stroked Circle, then for progress >= 1.0 draws a filled Circle on
top; because strokes are centered on the path, part of the stroke remains visible outside the filled
circle bounds, producing a halo despite the comment claiming a solid dot at progress==1.0.

Sources/RunnerBar/PieProgressView.swift[8-31]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`PieProgressView` documents that `progress == 1.0` should render as a solid filled circle, but the implementation always draws an outline stroke first. This can leave a visible halo/ring even at full progress.
### Issue Context
The outline is currently drawn with `.stroke(...)` on the same circle path; strokes extend half their width outside the path, so the outer portion can remain visible even when a filled circle is layered above.
### Fix Focus Areas
- Sources/RunnerBar/PieProgressView.swift[23-31]
### Suggested change
- Either render the outline only when `progress < 1.0`, **or** replace `.stroke(...)` with `.strokeBorder(...)` so the outline is drawn inside the bounds (and will be fully covered by the filled circle when `progress >= 1.0`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. reload() not main-actor guarded 🐞 Bug ☼ Reliability
Description
RunnerStoreObservable.reload() states it “must be called on the main thread” but does not enforce
that contract (no @MainActor / precondition). A background-thread call would mutate @Published state
off-main and can cause SwiftUI runtime warnings/crashes.
Code

Sources/RunnerBar/RunnerStoreObservable.swift[R23-34]

+    /// Copies current `RunnerStore.shared` state into the published properties.
+    /// Must be called on the main thread. Uses `withAnimation(nil)` to prevent
+    /// layout thrashing (RULE 5 — ref #52 #54 #57).
+    func reload() {
+        let store = RunnerStore.shared
+        withAnimation(nil) {
+            actions = store.actions
+            jobs = store.jobs
+            runners = store.runners
+            isRateLimited = store.isRateLimited
+        }
+    }
Evidence
The method explicitly documents a main-thread-only requirement but lacks any compile-time
(@MainActor) or runtime (Thread.isMainThread/precondition) guard, while directly assigning to
@Published properties inside reload().

Sources/RunnerBar/RunnerStoreObservable.swift[23-34]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`RunnerStoreObservable.reload()` is documented as main-thread-only but is not enforced. If any caller invokes it off the main thread, it will update `@Published` properties from a background thread, which can crash or emit SwiftUI runtime warnings.
### Issue Context
This is a small hardening change that makes the documented contract real and prevents accidental misuse.
### Fix Focus Areas
- Sources/RunnerBar/RunnerStoreObservable.swift[23-34]
### Suggested change
- Prefer compile-time enforcement by annotating either:
- `@MainActor final class RunnerStoreObservable: ObservableObject { ... }`, or
- `@MainActor func reload() { ... }`
- Optionally add a runtime check like `precondition(Thread.isMainThread)` at the start of `reload()` for extra safety (especially if not using `@MainActor`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 8, 2026

🐙 Octopus Review — PR #296

Summary

This PR is a significant redesign of PopoverMainView (#296): it decomposes the monolithic root view into sub-components (PopoverHeaderView, ActionsListView, ActionRowView, InlineJobsView, InlineJobRowView, RunnersListView), introduces a PieProgressView progress dot, adds pagination ("Load more"), filters runners to busy-only, and ships inline job sub-rows under in-progress action groups. It also refactors AppDelegate to resize the popover on each navigation step (instead of fixing the size once at open), extracts RunnerStoreObservable into its own file with @MainActor, adds RelativeTimeFormatter, and raises several display caps (cache: 3→30, groups: 5→50, job display cap: 5→30).

Score

Category Score Notes
Security 4/5 Removed helpful path-traversal comment but guard is preserved
Code Quality 4/5 Clean decomposition; minor concerns below
Performance 3/5 navigate() calls setFrameSize + contentSize on every nav while popover is open — triggers layout on main thread each time
Error Handling 3/5 job.htmlUrl force-unwrap removed (good); enrichGroupIfNeeded silently returns stale group on network error
Consistency 4/5 JobStep.startedAt/completedAt changed from String? to Date? — call sites in AppDelegate.enrichStepsIfNeeded may diverge
Overall 3/5 Performance and error handling concerns

Risk Assessment

Metric Value
Overall Risk 🟡 Medium
Complexity High
Test Coverage Impact Needs Attention
Breaking Change Yes — JobStep date fields changed from String? to Date?; ActionRunsResponse/RunPayload/HeadCommit/PRRef changed from private to internal

Positive Highlights

  1. PieProgressView regression guard comment is excellent. The inline ⚠️ note explaining why GeometryReader must have an explicit .frame(width: size, height: size) saves the next developer from a non-obvious layout bug. Pattern matches the existing regression guards throughout the codebase.

  2. RelativeTimeFormatter is cleanly testable. Making now an injectable parameter (relativeTo now: Date = Date()) is exactly the right design for a pure time-formatting utility — zero friction to test without mocking the system clock.

  3. @MainActor on RunnerStoreObservable. Moving from manual DispatchQueue.main.async discipline to compile-time enforcement is the right direction and eliminates a whole class of potential threading bugs.


Important Files Changed

Filename Overview
Sources/RunnerBar/PopoverMainView.swift Full redesign into sub-views; removes Active Jobs section, adds inline job rows under actions, runners filtered to busy-only, header replaced with stat chips
Sources/RunnerBar/AppDelegate.swift navigate() now resizes popover to each view's fittingSize; openPopover() deferred one tick; @MainActor annotations added; fixedWidth 340→420
Sources/RunnerBar/ActionGroup.swift Codable helpers extracted to ActionRunsResponse.swift; ActionGroup gains Equatable, progressFraction, startedAgo; cache limits raised
Sources/RunnerBar/RunnerStoreObservable.swift Extracted from inline definition in PopoverMainView; promoted to @MainActor
Sources/RunnerBar/PieProgressView.swift New file — radial pie progress dot replacing plain Circle()
Sources/RunnerBar/ActionRunsResponse.swift New file — RunPayload, HeadCommit, PRRef, prLabel() extracted from ActionGroup.swift
Sources/RunnerBar/RelativeTimeFormatter.swift New file — injectable relative-time formatter
Sources/RunnerBar/RunnerStoreState.swift Cache limits raised (3→30 jobs, 5→50 groups); enrichGroupJobs() added
Sources/RunnerBar/SystemStats.swift SystemStatsViewModel gains start()/stop() lifecycle; private serial samplingQueue replaces global utility queue

Diagram

graph TD
    AppDelegate["AppDelegate"]
    PopoverMainView["PopoverMainView\n(redesigned)"]
    PopoverHeaderView["PopoverHeaderView\n(new)"]
    RunnersListView["RunnersListView\n(new — busy only)"]
    ActionsListView["ActionsListView\n(new + pagination)"]
    ActionRowView["ActionRowView\n(new)"]
    InlineJobsView["InlineJobsView\n(new)"]
    InlineJobRowView["InlineJobRowView\n(new)"]
    PieProgressView["PieProgressView\n(new)"]
    RelativeTimeFormatter["RelativeTimeFormatter\n(new)"]
    ActionRunsResponse["ActionRunsResponse.swift\n(extracted)"]
    RunnerStoreObservable["RunnerStoreObservable\n(@MainActor — extracted)"]
    SystemStatsViewModel["SystemStatsViewModel\n(start/stop lifecycle)"]
    Navigate["navigate()\n(now resizes popover)"]

    AppDelegate --> PopoverMainView
    AppDelegate --> Navigate
    PopoverMainView --> PopoverHeaderView
    PopoverMainView --> RunnersListView
    PopoverMainView --> ActionsListView
    PopoverMainView --> RunnerStoreObservable
    ActionsListView --> ActionRowView
    ActionRowView --> InlineJobsView
    ActionRowView --> PieProgressView
    InlineJobsView --> InlineJobRowView
    InlineJobRowView --> PieProgressView
    PopoverHeaderView --> SystemStatsViewModel
    ActionRowView --> RelativeTimeFormatter
    ActionRunsResponse -. "extracted from" .-> ActionGroup["ActionGroup.swift"]

    classDef changed fill:#fbbf24,stroke:#92400e,color:#000
    classDef added fill:#4ade80,stroke:#166534,color:#000
    classDef unchanged fill:#374151,stroke:#6b7280,color:#e5e7eb

    class PopoverMainView,AppDelegate,RunnerStoreObservable,SystemStatsViewModel,ActionGroup changed
    class PopoverHeaderView,RunnersListView,ActionsListView,ActionRowView,InlineJobsView,InlineJobRowView,PieProgressView,RelativeTimeFormatter,ActionRunsResponse added
    class Navigate unchanged
Loading

Last reviewed commit: local

Checklist

  • No hardcoded secrets or credentials
  • Error handling is comprehensive — enrichGroupIfNeeded silently swallows fetch errors
  • Edge cases are covered (busy runner list, empty action list, nil htmlUrl)
  • Naming is clear and consistent with codebase conventions
  • No unnecessary dependencies added
  • JobStep.startedAt/completedAt type change (String?Date?) — verify all callers are updated
  • API changes are backward compatible (internal, single-binary app)


Reviewed by Octopus

@codeant-ai codeant-ai Bot added the size:M label May 8, 2026
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 1 inline finding. See the pinned Octopus summary comment for the full review.

/// All known self-hosted runners.
@Published private(set) var runners: [Runner] = []
/// `true` when the most recent poll hit a GitHub rate limit.
@Published private(set) var isRateLimited: Bool = false
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 private(set) on @Published property has no effect — mutations inside the module are unrestricted

private(set) restricts the synthesized setter to the declaring file, but @Published wraps the stored property in a Published<T> struct whose projected value ($actions, etc.) remains accessible anywhere in the module. More importantly, the _actions backing storage can still be mutated from other files within the same module via @Published's internal mechanics. In practice the intent is correct — only reload() should write — but the access-control annotation does not enforce this. Consider private var _actions: [ActionGroup] = [] with a computed var actions: [ActionGroup] { _actions } pattern, or accept the current state and add a comment that private(set) is documentation-by-convention rather than enforcement.

Suggested change
@Published private(set) var isRateLimited: Bool = false
// Option A — document the intent explicitly:
/// External reads only. Write access intentionally limited to reload().
@Published private(set) var actions: [ActionGroup] = []
// Option B — if strict enforcement is needed, remove @Published and
// drive change notifications manually:
private var _actions: [ActionGroup] = [] {
willSet { objectWillChange.send() }
}
var actions: [ActionGroup] { _actions }
AI Fix Prompt
Fix the following Medium (Bug) issue in `Sources/RunnerBar/RunnerStoreObservable.swift` at lines 12-19:

Problem: `private(set)` restricts the synthesized setter to the declaring file, but `@Published` wraps the stored property in a `Published<T>` struct whose projected value (`$actions`, etc.) remains accessible anywhere in the module. More importantly, the `_actions` backing storage can still be mutated from other files within the same module via `@Published`'s internal mechanics. In practice the intent is correct — only `reload()` should write — but the access-control annotation does not enforce this. Consider `private var _actions: [ActionGroup] = []` with a computed `var actions: [ActionGroup] { _actions }` pattern, or accept the current state and add a comment that `private(set)` is documentation-by-convention rather than enforcement.

Suggested fix:
// Option A — document the intent explicitly:
/// External reads only. Write access intentionally limited to reload().
@Published private(set) var actions: [ActionGroup] = []

// Option B — if strict enforcement is needed, remove @Published and
// drive change notifications manually:
private var _actions: [ActionGroup] = [] {
    willSet { objectWillChange.send() }
}
var actions: [ActionGroup] { _actions }

Comment thread Sources/RunnerBar/PieProgressView.swift Outdated
Comment on lines +28 to +31
Circle()
.trim(from: 0, to: CGFloat(progress))
.rotation(.degrees(-90))
.stroke(color, style: StrokeStyle(lineWidth: size * 0.25, lineCap: .round))
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The partial-progress branch draws only a stroked arc on the circumference, which does not match the component's stated "radial pie fill" behavior for 0 < progress < 1. This will render a ring segment instead of a filled pie wedge, so intermediate progress states are visually incorrect. Render a filled sector (pie slice) for partial progress instead of using stroke on a trimmed circle. [incomplete implementation]

Severity Level: Major ⚠️
- ⚠️ PieProgressView shows ring arc not radial pie.
- ⚠️ In-progress jobs miscommunicate completion fraction visually.
Steps of Reproduction ✅
1. In any SwiftUI view, render `PieProgressView(progress: 0.5, color: .green)` so that the
view defined in `Sources/RunnerBar/PieProgressView.swift:13-35` is displayed on screen.

2. At runtime, the `body` computed property at
`Sources/RunnerBar/PieProgressView.swift:21-35` evaluates the conditional; with `0.0 <
progress < 1.0`, the `else if progress > 0` branch at line 27 executes.

3. That branch constructs a `Circle()` trimmed from 0 to `CGFloat(progress)` and rotated
(lines 28-30), then applies `.stroke(color, style: StrokeStyle(lineWidth: size * 0.25,
lineCap: .round))` at line 31, which draws only the circumference as an arc segment.

4. On screen, you observe a thin ring arc over the faint outline (lines 23-24) rather than
a filled radial pie wedge, which contradicts the documented "radial pie fill" behavior
described in the header comment at `Sources/RunnerBar/PieProgressView.swift:5-10`.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** Sources/RunnerBar/PieProgressView.swift
**Line:** 28:31
**Comment:**
	*Incomplete Implementation: The partial-progress branch draws only a stroked arc on the circumference, which does not match the component's stated "radial pie fill" behavior for `0 < progress < 1`. This will render a ring segment instead of a filled pie wedge, so intermediate progress states are visually incorrect. Render a filled sector (pie slice) for partial progress instead of using `stroke` on a trimmed circle.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 8, 2026

CodeAnt AI finished reviewing your PR.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/RunnerBar/RunnerStoreObservable.swift`:
- Around line 11-34: The RunnerStoreObservable class mutates several `@Published`
properties from reload() without compile-time main-thread enforcement; annotate
the type with `@MainActor` (i.e., add `@MainActor` before final class
RunnerStoreObservable) so all methods including reload() and property accesses
are isolated to the main actor, preventing background calls from compiling and
matching the pattern used by LocalRunnerStore.refresh().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5e7313cb-07ac-4456-a746-62ba8eb4953f

📥 Commits

Reviewing files that changed from the base of the PR and between 2ec9608 and 086162e.

📒 Files selected for processing (2)
  • Sources/RunnerBar/PieProgressView.swift
  • Sources/RunnerBar/RunnerStoreObservable.swift

Comment thread Sources/RunnerBar/RunnerStoreObservable.swift
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/RunnerBar/PopoverMainView.swift`:
- Around line 39-58: The icon-only buttons in PopoverMainView.swift lack
accessibility labels and the 7x7 auth Circle is too small; update the Button
that calls signInWithGitHub to add an explicit accessibilityLabel (e.g., "Sign
in with GitHub") and increase its hit area by wrapping the Circle in a larger
tappable container (e.g., apply padding or a fixed frame/ContentShape so the
touch target is at least ~44x44 points) while keeping the visible circle small,
and add explicit accessibilityLabel modifiers to the settings Button
(onSelectSettings, e.g., "Settings") and the quit Button
(NSApplication.shared.terminate, e.g., "Quit RunnerBar") so each control has a
clear accessible name.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: faf9052d-f8cc-480a-99bc-118e39d0a3e5

📥 Commits

Reviewing files that changed from the base of the PR and between 086162e and b05078b.

📒 Files selected for processing (2)
  • Sources/RunnerBar/PopoverMainView.swift
  • Sources/RunnerBar/SystemStatsView.swift

Comment thread Sources/RunnerBar/PopoverMainView.swift Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
Sources/RunnerBar/PopoverMainView.swift (1)

41-61: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add explicit accessibility names and a real hit target for the header controls.

The auth badge is still a 7x7 tap target, and the three icon-only controls still rely on visual affordances/tooltips instead of explicit accessibility labels. That keeps the VoiceOver issue from the earlier review unresolved.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RunnerBar/PopoverMainView.swift` around lines 41 - 61, The three
header controls (auth badge, settings, quit) currently have tiny or no real hit
targets and lack explicit VoiceOver labels; update the Button for
signInWithGitHub (the Circle badge) and the Buttons that call onSelectSettings
and NSApplication.shared.terminate to provide explicit accessibility labels via
.accessibilityLabel(...) (e.g., "Sign in with GitHub", "Settings", "Quit
RunnerBar") and expand their hit areas (e.g., add padding or a minimum tappable
frame like minWidth/minHeight or .contentShape(Rectangle()) around the
Circle/Image) so the small Circle().fill(...) used in signInWithGitHub becomes a
visually small badge but a larger tappable target; ensure these changes are
applied to the Button wrappers around signInWithGitHub, onSelectSettings and the
quit action.
🧹 Nitpick comments (1)
Sources/RunnerBar/PopoverMainView.swift (1)

86-243: 🏗️ Heavy lift

Please extract the action-list row rendering into dedicated views/files.

PopoverMainView is now carrying header chrome, auth affordances, pagination state, action-row layout, inline-job-row layout, and status-formatting helpers in one type. This redesign would be much easier to maintain if the row views moved out instead of growing this file further.

As per coding guidelines, Sources/RunnerBar/*.swift: Keep files small and single-responsibility — add new files rather than growing existing ones.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RunnerBar/PopoverMainView.swift` around lines 86 - 243,
PopoverMainView is doing too much: extract the action-row and inline-job-row UI
and related small helpers into dedicated SwiftUI view structs/files (e.g.
ActionRowView and InlineJobRowView) to reduce file size. Move the ForEach body
that renders a Button for each actionGroup (the HStack using PieProgressView,
Text(actionGroup.label), Text(actionGroup.title),
startedAgo/elapsed/jobProgress/status and chevron) into ActionRowView that
accepts an ActionGroup, onSelectAction callback, and styling helpers
(actionDotColor, actionStatusLabel, actionStatusColor) via parameters or a small
protocol/closure set; similarly move the inner ForEach job Button HStack into
InlineJobRowView accepting ActiveJob and onSelectJob plus jobDotColor. Keep pure
formatting helpers (actionStatusLabel/actionStatusColor/actionDot/jobDot) either
as static helpers in a small new file or pass them in, and update
PopoverMainView to use these new views while preserving visibleCount pagination
and Load more button behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/RunnerBar/PopoverMainView.swift`:
- Around line 31-32: PopoverMainView's pagination is sticky because visibleCount
is never reset and the "Load more" button doesn't show a terminal "No more
actions" state; update the Load more UI to show the button only when
visibleCount < actions.count and otherwise show a disabled/terminal "No more
actions" view, and reset visibleCount back to its initial value (10) whenever
the underlying actions list or scope changes (e.g., add an onChange/onReceive
for the actions/scope that sets visibleCount = 10). Use the existing
visibleCount state and the action list identifier(s) in PopoverMainView to
implement the conditional rendering and the reset.
- Around line 138-173: The inline job rows are currently interactive but should
be passive informational rows: remove the Button wrapper, the onSelectJob call,
the .buttonStyle(.plain) and the chevron.right Image; instead render a static
HStack per job (inside the same ForEach over actionGroup.jobs) that uses
PieProgressView with a computed fraction from the job's steps (currentStepIndex
/ max(1, totalSteps)) instead of the hard-coded 0.5, and replace the job
name/status texts with the active step title and a step fraction label (e.g.,
"3/5" or "Step: Running tests"); keep jobDotColor(for: job), job.elapsed
display, padding, and accessibility but ensure the row is not tappable or
navigable.

---

Duplicate comments:
In `@Sources/RunnerBar/PopoverMainView.swift`:
- Around line 41-61: The three header controls (auth badge, settings, quit)
currently have tiny or no real hit targets and lack explicit VoiceOver labels;
update the Button for signInWithGitHub (the Circle badge) and the Buttons that
call onSelectSettings and NSApplication.shared.terminate to provide explicit
accessibility labels via .accessibilityLabel(...) (e.g., "Sign in with GitHub",
"Settings", "Quit RunnerBar") and expand their hit areas (e.g., add padding or a
minimum tappable frame like minWidth/minHeight or .contentShape(Rectangle())
around the Circle/Image) so the small Circle().fill(...) used in
signInWithGitHub becomes a visually small badge but a larger tappable target;
ensure these changes are applied to the Button wrappers around signInWithGitHub,
onSelectSettings and the quit action.

---

Nitpick comments:
In `@Sources/RunnerBar/PopoverMainView.swift`:
- Around line 86-243: PopoverMainView is doing too much: extract the action-row
and inline-job-row UI and related small helpers into dedicated SwiftUI view
structs/files (e.g. ActionRowView and InlineJobRowView) to reduce file size.
Move the ForEach body that renders a Button for each actionGroup (the HStack
using PieProgressView, Text(actionGroup.label), Text(actionGroup.title),
startedAgo/elapsed/jobProgress/status and chevron) into ActionRowView that
accepts an ActionGroup, onSelectAction callback, and styling helpers
(actionDotColor, actionStatusLabel, actionStatusColor) via parameters or a small
protocol/closure set; similarly move the inner ForEach job Button HStack into
InlineJobRowView accepting ActiveJob and onSelectJob plus jobDotColor. Keep pure
formatting helpers (actionStatusLabel/actionStatusColor/actionDot/jobDot) either
as static helpers in a small new file or pass them in, and update
PopoverMainView to use these new views while preserving visibleCount pagination
and Load more button behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d4e7264c-3725-4d4f-a6d7-d2144d326c64

📥 Commits

Reviewing files that changed from the base of the PR and between b05078b and 93f2695.

📒 Files selected for processing (2)
  • Sources/RunnerBar/ActionGroup.swift
  • Sources/RunnerBar/PopoverMainView.swift

Comment thread Sources/RunnerBar/PopoverMainView.swift Outdated
Comment thread Sources/RunnerBar/PopoverMainView.swift Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (1)
Sources/RunnerBar/PopoverMainView.swift (1)

88-217: 🏗️ Heavy lift

Please extract the new row/layout logic out of PopoverMainView.

This view now owns pagination, action rows, inline job rows, runner rows, and multiple status-mapping helpers. Splitting those into dedicated Sources/RunnerBar/*.swift views would bring this file back toward single-responsibility and make the redesign easier to maintain.

As per coding guidelines, Sources/RunnerBar/*.swift: Keep files small and single-responsibility — add new files rather than growing existing ones.

Also applies to: 247-293

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RunnerBar/PopoverMainView.swift` around lines 88 - 217,
PopoverMainView has grown too large: extract the action row, inline job row,
runners list, and pagination into small view files under
Sources/RunnerBar/*.swift. Create ActionRowView (renders a single actionGroup
row and uses PieProgressView, actionStatusLabel/actionStatusColor/actionDotColor
helpers), JobRowView (renders a single job row and uses jobDotColor),
ActionListView (wraps the ScrollView + LazyVStack, handles visibleCount
pagination and the "Load more" button, calls onSelectAction/onSelectJob), and
RunnerRowView (renders each runner row); move status-mapping helpers
(actionStatusLabel, actionStatusColor, actionDotColor, jobDotColor) into a
shared helper or ViewModel used by these new views, then replace the large
inline blocks in PopoverMainView with calls to ActionListView and a ForEach of
RunnerRowView, wiring up the same parameters and callbacks.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/RunnerBar/PopoverMainView.swift`:
- Around line 43-49: The popover currently uses signInWithGitHub and a help
string that instructs users to "set up a GitHub token", which violates the rule
against prompting for PATs inside the app; change the Button's action from
signInWithGitHub to a neutral settings/auth entry point (e.g., openAuthSettings
or openPreferences) and update the help text to a neutral message such as "Not
authenticated — open Settings to connect an account" (remove any mention of
token/PAT), leaving the visual Circle/button style unchanged so callers
referencing isAuthenticated, Button, and the help modifier still work.
- Around line 195-217: The runner subsection is currently rendered after the
actions list and only shows statusDescription; move the active runners block
(the logic using localRunners.runners and the activeRunners filter) so it
appears between the header and the actions list, and for each runner (identified
by runner in the ForEach) replace the current small HStack (which uses
runnerDotColor(for:), runner.runnerName, runner.statusDescription) with a
chevron-style row that shows the runner dot, runner.runnerName, and on the
trailing side CPU% and memory in GB (calculate and format per-runner values from
the Runner model), keeping truncation/lineLimit behavior and using
runnerDotColor(for:) for both dot and the right-side text color; ensure layout
matches the chevron affordance (leading content, trailing stacked CPU% and MEM
(GB), and a chevron indicator).
- Around line 57-63: The Button in PopoverMainView currently calls
NSApplication.shared.terminate(nil) which quits the app; change the
Button(action:) to dismiss the popover instead. Replace the terminate call
inside the Button action with logic that closes the popover hosting this view
(for example use presentationMode.wrappedValue.dismiss() if the view has
`@Environment`(\\.presentationMode) var presentationMode, or call
NSPopover.performClose(sender) / close the hosting window) so the "xmark" only
closes the popover; update any required `@Environment` or popover reference in
PopoverMainView to enable that dismissal.
- Around line 247-261: The actionStatusLabel(for:) function currently returns
mixed-case labels; update it to return the approved Phase 3 uppercase labels. In
actionStatusLabel(for:) map group.groupStatus .inProgress -> "IN PROGRESS",
.queued -> "QUEUED", and for .completed map group.conclusion values to
uppercase: "success" -> "SUCCESS", "failure" -> "FAILED", both "cancelled" and
"canceled" -> "CANCELED", "skipped" -> "SKIPPED", and use "DONE" as the default;
keep the function and switch structure (actionStatusLabel, group.groupStatus,
group.conclusion) unchanged.

---

Nitpick comments:
In `@Sources/RunnerBar/PopoverMainView.swift`:
- Around line 88-217: PopoverMainView has grown too large: extract the action
row, inline job row, runners list, and pagination into small view files under
Sources/RunnerBar/*.swift. Create ActionRowView (renders a single actionGroup
row and uses PieProgressView, actionStatusLabel/actionStatusColor/actionDotColor
helpers), JobRowView (renders a single job row and uses jobDotColor),
ActionListView (wraps the ScrollView + LazyVStack, handles visibleCount
pagination and the "Load more" button, calls onSelectAction/onSelectJob), and
RunnerRowView (renders each runner row); move status-mapping helpers
(actionStatusLabel, actionStatusColor, actionDotColor, jobDotColor) into a
shared helper or ViewModel used by these new views, then replace the large
inline blocks in PopoverMainView with calls to ActionListView and a ForEach of
RunnerRowView, wiring up the same parameters and callbacks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d6a83ee0-d2bc-4ff9-9d95-0ac18274b011

📥 Commits

Reviewing files that changed from the base of the PR and between 93f2695 and 4556f24.

📒 Files selected for processing (1)
  • Sources/RunnerBar/PopoverMainView.swift

Comment thread Sources/RunnerBar/PopoverMainView.swift Outdated
Comment thread Sources/RunnerBar/PopoverMainView.swift Outdated
Comment thread Sources/RunnerBar/PopoverMainView.swift Outdated
Comment thread Sources/RunnerBar/PopoverMainView.swift Outdated
@eonist
Copy link
Copy Markdown
Collaborator Author

eonist commented May 8, 2026

Code Review — PR #311 feature/296-popover-main-view-redesign

Overall this is a solid implementation of the 6-phase redesign. The structure is correct and the regression guards are respected. Below are findings ordered by severity.


🔴 Bugs / Spec Violations

1. actionStatusLabel uses wrong casing — spec requires uppercase

The spec (issue #178 and sub-issues #302, #285) explicitly mandates uppercase status strings: "IN PROGRESS", "SUCCESS", "FAILED", "CANCELED". The implementation returns title-case ("Running", "Success", "Failed", "Cancelled") which diverges from the spec.

// ❌ Current
case .inProgress: return "Running"
case "success":   return "Success"
case "failure":   return "Failed"
case "cancelled": return "Cancelled"

// ✅ Fix
case .inProgress: return "IN PROGRESS"
case "success":   return "SUCCESS"
case "failure":   return "FAILED"
case "cancelled": return "CANCELED"   // note: spec spelling is "CANCELED" not "CANCELLED"

2. Child job row status text also uses wrong casing

In the inline rows, the job status text is hardcoded as "Running" / "Queued" — same issue:

// ❌ Current
Text(job.status == "in_progress" ? "Running" : "Queued")

// ✅ Fix
Text(job.status == "in_progress" ? "IN PROGRESS" : "QUEUED")

3. [×] quits instead of hiding the popover

The close button calls NSApplication.shared.terminate(nil) which kills the app entirely. Per the spec the [×] button should hide the popover (same as clicking away), not quit. Quitting should be accessible from the Settings page.

// ❌ Current — quits the app
Button(action: { NSApplication.shared.terminate(nil) }) { ... }

// ✅ Fix — hide the app / close popover  
Button(action: { NSApplication.shared.hide(nil) }) { ... }
// OR call the existing popover-close mechanism if available via AppDelegate

4. Child job PieProgressView progress is hardcoded to 0.5

The row uses a static progress: job.status == "in_progress" ? 0.5 : 0.0 which never updates. It should reflect real step completion from ActiveJob:

// ❌ Current
PieProgressView(progress: job.status == "in_progress" ? 0.5 : 0.0, ...)

// ✅ Fix — use step fraction (as designed in #304)
let stepProgress: Double = {
    let total = job.steps.count
    guard total > 0 else { return job.status == "in_progress" ? 0.5 : 0.0 }
    let done = job.steps.filter { $0.conclusion != nil }.count
    return Double(done) / Double(total)
}()
PieProgressView(progress: stepProgress, color: jobDotColor(for: job), size: 7)

🟡 Issues / Polish

5. Brace indentation is inconsistent in the ScrollView / LazyVStack block

The LazyVStack and ScrollView closing braces have misaligned comments (// ForEach end, // LazyVStack end, // ScrollView end) and the ForEach body is indented at the same level as the LazyVStack content. This will likely trigger a SwiftLint closure_body_length or indentation warning and is hard to read.

// ❌ Current — braces at wrong indent levels
ScrollView(.vertical, showsIndicators: false) {
    LazyVStack(spacing: 0) {
// Phase 3 (#302): redesigned action row
// Layout: ...
ForEach(store.actions.prefix(visibleCount)) { actionGroup in
    ...
}
    } // LazyVStack end
} // ScrollView end

// ✅ Fix — consistent 4-space indentation throughout
ScrollView(.vertical, showsIndicators: false) {
    LazyVStack(spacing: 0) {
        // Phase 3 (#302): redesigned action row
        ForEach(store.actions.prefix(visibleCount)) { actionGroup in
            ...
        }
        // Load more button
        if store.actions.count > visibleCount { ... }
    }
}
.frame(maxHeight: 400)

6. "Load more" button label is dynamic but the count logic is off

Text("Load \(min(10, store.actions.count - visibleCount)) more")

This could display "Load 3 more" when the next page would actually add 10 items (because store.actions only holds items fetched so far). Since the store always fetches 50, the remaining count is correct when all items are local — but if pagination triggers a network fetch the math breaks. Simpler and more predictable:

Text("Load 10 more actions…")

7. Actions section label remains — spec removes it

The diff still includes:

Text("Actions")
    .font(.caption).foregroundColor(.secondary)
    .padding(.horizontal, 12).padding(.top, 8).padding(.bottom, 2)

Per the spec design (issue #178), there are no section labels — the list is self-evident. This should be removed along with the old System label (which is already gone ✅).

8. Runner rows have no > chevron and are not tappable

The spec (issue #307) states runner rows should have a > chevron and be tappable (navigating to a runner detail, or no-op if no detail exists yet). Currently the runner row is a plain HStack with no Button wrapper and no chevron. This should be:

Button(action: { /* onSelectRunner(runner) or no-op */ }) {
    HStack(spacing: 6) {
        ...
        Image(systemName: "chevron.right")
            .font(.caption2).foregroundColor(.secondary)
    }
    .padding(.horizontal, 12).padding(.vertical, 3)
}
.buttonStyle(.plain)

9. Runner section uses "Runners" section label — spec has none

Same as point 7 — the runner section has Text("Runners") which is not in the spec layout. Remove it for consistency.

10. RunnerStoreObservable caps actions comment says 5, should be 10

In RunnerStoreObservable.swift:

/// Action groups to display (live + recently completed, capped at 5).

The new default visibleCount is 10, not 5. Update the comment to reflect the new default.

11. .onDisappear missing on systemStats.stop()

The view calls systemStats.start() in .onAppear but never stops the timer. A missing .onDisappear { systemStats.stop() } means the 2-second polling timer keeps running even after the popover is dismissed, causing unnecessary CPU load. (This was flagged in the PR description for #313 as a fix — it should be here too.)

.onAppear {
    isAuthenticated = (githubToken() != nil)
    systemStats.start()
    Task { await localRunners.refresh() }
}
.onDisappear {
    systemStats.stop()
}

✅ What's Good

  • PieProgressView implementation is clean and correct — empty ring / partial arc / solid fill states all handled properly
  • startedAgo computed property on ActionGroup is well-guarded (negative time check, correct fallback to createdAt)
  • SystemStatsView.statsContent extraction is the right approach — backward compat body preserved ✅
  • Regression guard comment on lineLimit(1) is in the right place ✅
  • withAnimation(nil) in RunnerStoreObservable.reload() correctly suppresses layout thrash ✅
  • Auth badge reduced to an orange dot next to [⚙] — much cleaner than the old full text row ✅
  • visibleCount state management is correct (starts at 10, increments by 10)
  • Child job rows are individually tappable via onSelectJob

Summary

# Severity File Fix needed
1 🔴 Bug PopoverMainView.swift actionStatusLabel → uppercase per spec
2 🔴 Bug PopoverMainView.swift Child row status text → uppercase
3 🔴 Bug PopoverMainView.swift [×]hide not terminate
4 🔴 Bug PopoverMainView.swift Child PieProgressView progress → real step fraction
5 🟡 Polish PopoverMainView.swift Fix ScrollView/LazyVStack indent
6 🟡 Polish PopoverMainView.swift Simplify Load more label to static string
7 🟡 Spec PopoverMainView.swift Remove Text("Actions") section label
8 🟡 Spec PopoverMainView.swift Runner rows → Button + > chevron
9 🟡 Spec PopoverMainView.swift Remove Text("Runners") section label
10 🟡 Doc RunnerStoreObservable.swift Fix comment: "capped at 5" → "capped at 10"
11 🟡 Lifecycle PopoverMainView.swift Add .onDisappear { systemStats.stop() }

Items 1–4 should be resolved before merge. Items 5–11 are polish/spec conformance that would be good to land in the same PR rather than follow-ups.

🔴 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"
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 8, 2026

CodeAnt AI is running Incremental review

@codeant-ai codeant-ai Bot added size:L This PR changes 100-499 lines, ignoring generated files and removed size:M labels May 8, 2026
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 8, 2026

CodeAnt AI Incremental review completed.

eonist added 2 commits May 8, 2026 13:42
- #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
@eonist
Copy link
Copy Markdown
Collaborator Author

eonist commented May 8, 2026

🔍 Code Review — PR #311: PopoverMainView Redesign

Overall this is a well-structured, multi-phase redesign with solid inline documentation. The regression guards and fix references are commendable. A few observations and suggestions below.


✅ Strengths


⚠️ Issues & Suggestions

1. startedAgo is not timer-driven — it will go stale
startedAgo in ActionGroup.swift calls Date() at access time. SwiftUI won't re-render just because time passes, so rows showing e.g. "3m ago" will be frozen until the next store reload(). Worth noting as a known limitation in the doc comment:

// ⚠️ Value is only as fresh as the last store reload(). Not independently timer-driven.
var startedAgo: String {

2. Inline let closures inside ForEach body (compiler warning risk)

let stepProgress: Double = {
    let total = job.steps.count
    ...
}()

Immediately-invoked closures ({}()) inside a ViewBuilder body can trigger "result of call to closure ... is unused" warnings on some compiler versions and can confuse SwiftUI's diff engine. Safer to extract to a private helper:

private func stepProgress(for job: ActiveJob) -> Double { ... }

3. visibleCount is not reset on store refresh
If the store's actions array is replaced after a full poll refresh, visibleCount stays at whatever the user paginated to. This could expose stale or unexpectedly long lists. Consider resetting on change:

.onChange(of: store.actions.count) { visibleCount = 10 }

4. Runner button action is a no-op placeholder

Button(action: { /* runner detail view — no-op until #307 detail is implemented */ }, label: {

This creates a tappable row with a chevron that does nothing on tap — users will get no feedback. Consider either:


5. "CANCELED" (1 L) vs "CANCELLED" (2 L)
conclusionLabel now returns "CANCELED" for GitHub's API value "cancelled" (2 Ls). This is a user-visible inconsistency. Confirm this is intentional per spec, or align to "CANCELLED".


6. PR phase checklist is all unchecked
The PR description lists all 6 phases as - [ ]. If they're all implemented in this PR, check them off (- [x]) so reviewers can confirm scope at a glance.


7. RunnerStoreObservable doc comment mismatch
The doc comment says "capped at 10" and "capped at 3", but reload() copies store.actions and store.jobs without any .prefix() cap — the cap lives in the view via visibleCount. Update the doc comment to avoid confusion for future readers.


Minor Nits

  • The body wrapper in SystemStatsView for backward compat is clean — consider a @available(*, deprecated, renamed: "statsContent") note so future call sites prefer statsContent directly.
  • .frame(maxHeight: 400) on the ScrollView is hardcoded. Worth a follow-up ticket for adaptive sizing if screen real estate varies.

Overall: Approve pending minor fixes — especially #2 (inline closure extraction) and #4 (no-op tappable runner row UX). The architecture is solid and the inline documentation is well above average for this codebase. 🚀

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (5)
Sources/RunnerBar/PopoverMainView.swift (5)

42-49: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep the unauthenticated affordance neutral.

The help text still tells users to add a GitHub token. This flow was already redirected to Settings, so the copy should stay at that neutral level too.

As per coding guidelines, Sources/RunnerBar/*.swift: Do not prompt the user for a GitHub token or PAT directly in the app.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RunnerBar/PopoverMainView.swift` around lines 42 - 49, The help text
currently prompts the user to "add a GitHub token" even though the button now
routes to Settings; update the user-facing copy to be neutral by changing the
.help text on the unauthenticated Button (the Button using onSelectSettings and
the Circle indicator in PopoverMainView/PopoverMainView.swift) to something like
"Not authenticated — open Settings" so it does not mention adding a GitHub token
or PAT.

43-64: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add explicit accessibility names to the header controls.

The auth dot is still a tiny unlabeled target, and the gear / x buttons are icon-only without clear accessible names. That keeps the header hard to use with VoiceOver and makes the unauthenticated affordance easy to miss.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RunnerBar/PopoverMainView.swift` around lines 43 - 64, The header
buttons lack accessibility labels and the auth dot is a tiny unlabeled target;
update the Button for the auth dot (the branch guarded by isAuthenticated and
the Circle() view) to have a clear accessibility label (e.g., "Not
authenticated, open Settings") and a larger hit area if needed, and add explicit
.accessibilityLabel(...) to the Settings button (Image(systemName: "gearshape")
/ onSelectSettings) and the Hide button (Image(systemName: "xmark") /
NSApplication.shared.hide(nil)) so VoiceOver announces their purpose; ensure
labels are concise and consider adding .accessibilityAddTraits(.isButton) or a
descriptive accessibilityHint where helpful.

149-175: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Render step-level details in the inline job rows.

These rows still show job.name and a job-status label, and they still fall back to a synthetic 0.5 when job.steps is empty. Phase 4 calls for the current step title plus a done/total fraction, so the child rows still aren't surfacing the step data the redesign was meant to expose.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RunnerBar/PopoverMainView.swift` around lines 149 - 175, The inline
job rows currently display job.name and a synthetic 0.5 progress when job.steps
is empty; instead compute and surface the current step title and a done/total
fraction and remove the 0.5 fallback: update the stepProgress closure
(job.steps) to compute total = job.steps.count, done = job.steps.filter {
$0.conclusion != nil }.count, progress = total > 0 ? Double(done)/Double(total)
: 0.0, and derive currentStepTitle = job.steps.first(where: { $0.conclusion ==
nil })?.title ?? job.steps.first?.title ?? "No steps"; replace Text(job.name)
with Text(currentStepTitle) and replace the status/label Text that currently
shows "IN PROGRESS"/"QUEUED" or the static fraction with a Text showing
"\(done)/\(total)" (or "0/0" when empty) while keeping PieProgressView(progress:
stepProgress, ... ) and existing color/size/formatting.

197-223: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Phase 6 is still incomplete in the runners block.

This subsection is still rendered after the actions list and the rows still show statusDescription; the spec calls for the runners block between the header and actions, with per-runner CPU% and MEM (GB) in the trailing slot.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RunnerBar/PopoverMainView.swift` around lines 197 - 223, The runners
block is in the wrong place and displaying the wrong trailing info; move the
activeRunners block (the ForEach over localRunners.runners) so it appears
between the header and the actions list, remove any section label, and replace
the trailing Text(runner.statusDescription) with formatted CPU% and MEM (GB)
values (use the model properties like runner.cpuUsagePercent and runner.memoryGB
or compute them from existing fields) while keeping runnerDotColor(for:),
runner.runnerName and the chevron; ensure lineLimit/truncation and padding stay
the same and retain .buttonStyle(.plain).

31-32: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reset visibleCount when the actions dataset changes.

After a user expands the list once, later refreshes still inherit that larger count, so the Phase 5 default of 10 never comes back and the terminal “No more actions” state still can't be shown.

Also applies to: 181-190, 227-235

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RunnerBar/PopoverMainView.swift` around lines 31 - 32, The
visibleCount state must be reset to the default when the underlying actions
dataset changes; add logic in PopoverMainView to set visibleCount = 10 whenever
the actions collection/property updates (e.g. use SwiftUI's .onChange(of:
actions) { _ in visibleCount = 10 } or reset in the actions property's didSet /
the view model observer). Apply the same reset behavior where the other action
lists are handled (the blocks around the code referenced by symbols at the other
ranges) so any refresh/replace of the actions array restores the Phase 5 default
and allows "No more actions" to reappear.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/RunnerBar/PieProgressView.swift`:
- Around line 32-40: Rename the short variable r to a descriptive identifier
(e.g. radius or halfWidth) to satisfy SwiftLint identifier_name; update all
references where r is used (center initialization: CGPoint(x: radius, y: radius)
and the radius parameter in path.addArc) and ensure start and end Angle
calculations remain unchanged; run SwiftLint/CI to confirm the violation is
resolved.

---

Duplicate comments:
In `@Sources/RunnerBar/PopoverMainView.swift`:
- Around line 42-49: The help text currently prompts the user to "add a GitHub
token" even though the button now routes to Settings; update the user-facing
copy to be neutral by changing the .help text on the unauthenticated Button (the
Button using onSelectSettings and the Circle indicator in
PopoverMainView/PopoverMainView.swift) to something like "Not authenticated —
open Settings" so it does not mention adding a GitHub token or PAT.
- Around line 43-64: The header buttons lack accessibility labels and the auth
dot is a tiny unlabeled target; update the Button for the auth dot (the branch
guarded by isAuthenticated and the Circle() view) to have a clear accessibility
label (e.g., "Not authenticated, open Settings") and a larger hit area if
needed, and add explicit .accessibilityLabel(...) to the Settings button
(Image(systemName: "gearshape") / onSelectSettings) and the Hide button
(Image(systemName: "xmark") / NSApplication.shared.hide(nil)) so VoiceOver
announces their purpose; ensure labels are concise and consider adding
.accessibilityAddTraits(.isButton) or a descriptive accessibilityHint where
helpful.
- Around line 149-175: The inline job rows currently display job.name and a
synthetic 0.5 progress when job.steps is empty; instead compute and surface the
current step title and a done/total fraction and remove the 0.5 fallback: update
the stepProgress closure (job.steps) to compute total = job.steps.count, done =
job.steps.filter { $0.conclusion != nil }.count, progress = total > 0 ?
Double(done)/Double(total) : 0.0, and derive currentStepTitle =
job.steps.first(where: { $0.conclusion == nil })?.title ??
job.steps.first?.title ?? "No steps"; replace Text(job.name) with
Text(currentStepTitle) and replace the status/label Text that currently shows
"IN PROGRESS"/"QUEUED" or the static fraction with a Text showing
"\(done)/\(total)" (or "0/0" when empty) while keeping PieProgressView(progress:
stepProgress, ... ) and existing color/size/formatting.
- Around line 197-223: The runners block is in the wrong place and displaying
the wrong trailing info; move the activeRunners block (the ForEach over
localRunners.runners) so it appears between the header and the actions list,
remove any section label, and replace the trailing
Text(runner.statusDescription) with formatted CPU% and MEM (GB) values (use the
model properties like runner.cpuUsagePercent and runner.memoryGB or compute them
from existing fields) while keeping runnerDotColor(for:), runner.runnerName and
the chevron; ensure lineLimit/truncation and padding stay the same and retain
.buttonStyle(.plain).
- Around line 31-32: The visibleCount state must be reset to the default when
the underlying actions dataset changes; add logic in PopoverMainView to set
visibleCount = 10 whenever the actions collection/property updates (e.g. use
SwiftUI's .onChange(of: actions) { _ in visibleCount = 10 } or reset in the
actions property's didSet / the view model observer). Apply the same reset
behavior where the other action lists are handled (the blocks around the code
referenced by symbols at the other ranges) so any refresh/replace of the actions
array restores the Phase 5 default and allows "No more actions" to reappear.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 827d0ca7-9499-4c69-853a-5056258d732a

📥 Commits

Reviewing files that changed from the base of the PR and between 4556f24 and 87fff9b.

📒 Files selected for processing (3)
  • Sources/RunnerBar/PieProgressView.swift
  • Sources/RunnerBar/PopoverMainView.swift
  • Sources/RunnerBar/RunnerStoreObservable.swift

Comment thread Sources/RunnerBar/PieProgressView.swift Outdated
…queued jobs in active group

1. AppDelegate.navigate() now resizes the popover to the new view's
   fittingSize before swapping rootView, so detail views fit their
   content instead of inheriting the main view's height.

2. ActionDetailView.jobStatusLabel returns 'In Progress' for queued
   jobs when the parent group is in progress, matching spec #178.
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 8 inline findings. See the pinned Octopus summary comment for the full review.

pop.contentSize = size
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 navigate() calls setFrameSize + contentSize while popover is shown — contradicts regression guard

The REGRESSION GUARD comment at the top of AppDelegate explicitly forbids calling setFrameSize while popover.isShown == true from outside navigate(). The new navigate() implementation now does exactly that — it calls hc.view.setFrameSize(size) and pop.contentSize = size inside a DispatchQueue.main.async block that fires while the popover IS open (popoverIsOpen is true). Per the guard comment, this was v0.25's mistake: NSPopover sees contentSize change → re-anchors X+Y → left-jump. The guard was protecting against this. The comment was updated to say 'from outside navigate()' as a carve-out, but the underlying risk (anchor drift on contentSize change while shown) remains. Recommend profiling with a large number of action rows to verify no anchor drift occurs, and add an explicit guard: only resize if the new size differs from the current contentSize by more than a threshold.

Suggested change
DispatchQueue.main.async { [weak self] in
guard let self, self.popoverIsOpen else { return }
let fitting = hc.view.fittingSize
let width = fitting.width > 0 ? fitting.width : Self.fixedWidth
let height = min(max(fitting.height > 0 ? fitting.height : 300, Self.minHeight), Self.maxHeight)
let newSize = NSSize(width: width, height: height)
guard abs(newSize.height - (pop.contentSize.height)) > 1 ||
abs(newSize.width - (pop.contentSize.width)) > 1 else { return }
hc.view.setFrameSize(newSize)
pop.contentSize = newSize
}
AI Fix Prompt
Fix the following High (Bug) issue in `Sources/RunnerBar/AppDelegate.swift` at lines 264-272:

Problem: The REGRESSION GUARD comment at the top of AppDelegate explicitly forbids calling setFrameSize while popover.isShown == true from outside navigate(). The new navigate() implementation now does exactly that — it calls hc.view.setFrameSize(size) and pop.contentSize = size inside a DispatchQueue.main.async block that fires while the popover IS open (popoverIsOpen is true). Per the guard comment, this was v0.25's mistake: NSPopover sees contentSize change → re-anchors X+Y → left-jump. The guard was protecting against this. The comment was updated to say 'from outside navigate()' as a carve-out, but the underlying risk (anchor drift on contentSize change while shown) remains. Recommend profiling with a large number of action rows to verify no anchor drift occurs, and add an explicit guard: only resize if the new size differs from the current contentSize by more than a threshold.

Suggested fix:
DispatchQueue.main.async { [weak self] in
    guard let self, self.popoverIsOpen else { return }
    let fitting = hc.view.fittingSize
    let width = fitting.width > 0 ? fitting.width : Self.fixedWidth
    let height = min(max(fitting.height > 0 ? fitting.height : 300, Self.minHeight), Self.maxHeight)
    let newSize = NSSize(width: width, height: height)
    guard abs(newSize.height - (pop.contentSize.height)) > 1 ||
          abs(newSize.width  - (pop.contentSize.width))  > 1 else { return }
    hc.view.setFrameSize(newSize)
    pop.contentSize = newSize
}

name: step.name,
status: step.status,
conclusion: step.conclusion,
startedAt: step.startedAt,
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.startedAt / completedAt silently changed from String? to Date? — makeActiveJob call in enrichStepsIfNeeded passes raw String fields

In this diff, makeActiveJob now passes step.startedAt and step.completedAt directly (without iso.date(from:)) because the diff removes the flatMap conversion for JobStep fields. However, JobPayload.steps is typed as [JobStep]? and JobStep inherits its field types from the struct definition. If JobStep.startedAt and .completedAt were changed from String? to Date? (as suggested by the removal of iso.date(from:) calls), then JobPayload must also decode them as Date? — but JobPayload.steps is decoded from JSON which delivers ISO 8601 strings, not Date objects. This would cause a silent decode failure (steps array decoded as nil/empty) every time enrichStepsIfNeeded runs, causing step data to never populate. Verify that JobStep's Decodable conformance includes a custom date-decoding strategy, or revert the step field types to String? and restore the flatMap.

Suggested change
startedAt: step.startedAt,
// Option A: restore the iso conversion in makeActiveJob
let steps: [JobStep] = (payload.steps ?? []).enumerated().map { idx, step in
JobStep(
id: idx + 1,
name: step.name,
status: step.status,
conclusion: step.conclusion,
startedAt: step.startedAt.flatMap { iso.date(from: $0) },
completedAt: step.completedAt.flatMap { iso.date(from: $0) }
)
}
// Option B: configure JSONDecoder with .iso8601 dateDecodingStrategy before decoding JobPayload
AI Fix Prompt
Fix the following High (Bug) issue in `Sources/RunnerBar/ActionGroup.swift` at lines 296-312:

Problem: In this diff, makeActiveJob now passes step.startedAt and step.completedAt directly (without iso.date(from:)) because the diff removes the flatMap conversion for JobStep fields. However, JobPayload.steps is typed as [JobStep]? and JobStep inherits its field types from the struct definition. If JobStep.startedAt and .completedAt were changed from String? to Date? (as suggested by the removal of iso.date(from:) calls), then JobPayload must also decode them as Date? — but JobPayload.steps is decoded from JSON which delivers ISO 8601 strings, not Date objects. This would cause a silent decode failure (steps array decoded as nil/empty) every time enrichStepsIfNeeded runs, causing step data to never populate. Verify that JobStep's Decodable conformance includes a custom date-decoding strategy, or revert the step field types to String? and restore the flatMap.

Suggested fix:
// Option A: restore the iso conversion in makeActiveJob
let steps: [JobStep] = (payload.steps ?? []).enumerated().map { idx, step in
    JobStep(
        id: idx + 1,
        name: step.name,
        status: step.status,
        conclusion: step.conclusion,
        startedAt: step.startedAt.flatMap { iso.date(from: $0) },
        completedAt: step.completedAt.flatMap { iso.date(from: $0) }
    )
}
// Option B: configure JSONDecoder with .iso8601 dateDecodingStrategy before decoding JobPayload


/// Navigation level 1: runner status + jobs + actions.
@MainActor
private func mainView() -> AnyView {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 enrichGroupIfNeeded makes a blocking network call on the main thread when called from onSelectAction

enrichGroupIfNeeded calls fetchActionGroups(for:) which is a blocking gh CLI invocation. The call site in onSelectAction dispatches to DispatchQueue.global(qos: .userInitiated) before calling enrichGroupIfNeeded, so the fetch itself runs on a background thread — this is correct. However, enrichGroupIfNeeded is also called internally without any background dispatch, and the function itself has no threading annotation. If a future caller invokes it on the main thread (e.g. from validatedView(for:) in openPopover), it will block the main thread for several seconds. Add a comment asserting the background-thread contract, or mark with dispatchPrecondition.

Suggested change
private func mainView() -> AnyView {
private func enrichGroupIfNeeded(_ group: ActionGroup) -> ActionGroup {
dispatchPrecondition(condition: .notOnQueue(.main))
guard group.jobs.isEmpty else { return group }
let fetched = fetchActionGroups(for: group.repo)
return fetched.first(where: { $0.id == group.id }) ?? group
}
AI Fix Prompt
Fix the following Medium (Performance) issue in `Sources/RunnerBar/AppDelegate.swift` at lines 96-101:

Problem: enrichGroupIfNeeded calls fetchActionGroups(for:) which is a blocking gh CLI invocation. The call site in onSelectAction dispatches to DispatchQueue.global(qos: .userInitiated) before calling enrichGroupIfNeeded, so the fetch itself runs on a background thread — this is correct. However, enrichGroupIfNeeded is also called internally without any background dispatch, and the function itself has no threading annotation. If a future caller invokes it on the main thread (e.g. from validatedView(for:) in openPopover), it will block the main thread for several seconds. Add a comment asserting the background-thread contract, or mark with dispatchPrecondition.

Suggested fix:
private func enrichGroupIfNeeded(_ group: ActionGroup) -> ActionGroup {
    dispatchPrecondition(condition: .notOnQueue(.main))
    guard group.jobs.isEmpty else { return group }
    let fetched = fetchActionGroups(for: group.repo)
    return fetched.first(where: { $0.id == group.id }) ?? group
}

private struct PRRef: Codable { let number: Int }

// MARK: - PR label

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 conformance line exceeds line_length rule and is hard to maintain

The custom == implementation packs four conditions onto one line, exceeding the configured line_length warning threshold and making it hard to verify correctness. The runs comparison uses map({ $0.id }) == which works but allocates two arrays on every equality check — common during SwiftUI diffing. A minor readability and allocation concern.

Suggested change
static func == (lhs: ActionGroup, rhs: ActionGroup) -> Bool {
lhs.id == rhs.id
&& lhs.isDimmed == rhs.isDimmed
&& lhs.jobs == rhs.jobs
&& lhs.runs.map(\.id) == rhs.runs.map(\.id)
}
AI Fix Prompt
Fix the following Medium (Style) issue in `Sources/RunnerBar/ActionGroup.swift` at line 172:

Problem: The custom == implementation packs four conditions onto one line, exceeding the configured line_length warning threshold and making it hard to verify correctness. The runs comparison uses map({ $0.id }) == which works but allocates two arrays on every equality check — common during SwiftUI diffing. A minor readability and allocation concern.

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

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 boundary skips 2-day display — jumps straight from hours to days

The switch uses ..<172_800 (48h) for the hours bucket, meaning at exactly 48h it switches to days (showing '2d ago'). This is fine at the boundary, but the range ..<3_600 → ..<172_800 means any value between 1h and 48h shows in hours (e.g. '36h ago') rather than switching to days at 24h. Most relative-time formatters switch to days at 24h. This is a minor UX inconsistency — '27h ago' reads oddly compared to '1d ago'. Consider using 86_400 (24h) as the hours/days boundary.

Suggested change
}
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 Low (Logic Error) issue in `Sources/RunnerBar/RelativeTimeFormatter.swift` at lines 27-30:

Problem: The switch uses ..<172_800 (48h) for the hours bucket, meaning at exactly 48h it switches to days (showing '2d ago'). This is fine at the boundary, but the range ..<3_600 → ..<172_800 means any value between 1h and 48h shows in hours (e.g. '36h ago') rather than switching to days at 24h. Most relative-time formatters switch to days at 24h. This is a minor UX inconsistency — '27h ago' reads oddly compared to '1d ago'. Consider using 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"
}

Comment thread Sources/RunnerBar/PopoverMainView.swift Outdated

// MARK: - ActionsListView

/// Actions list with per-group expand/collapse and pagination (Phase 3–5 / #302 #304 #305).
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 NSApplication.shared.hide(nil) in PopoverHeaderView closes the app, not just the popover

The close (✕) button in PopoverHeaderView calls NSApplication.shared.hide(nil), which hides the entire application (equivalent to Cmd+H). For a menu bar app, the expected behaviour of a close/dismiss button is to close the popover only. The correct call is popover.performClose(nil) or going through a callback. Since PopoverHeaderView doesn't have a reference to the popover, the cleanest fix is to pass an onClose callback from AppDelegate, similar to how onSelectSettings is passed.

Suggested change
/// Actions list with per-group expand/collapse and pagination (Phase 3–5 / #302 #304 #305).
// In PopoverHeaderView, add:
let onClose: () -> Void
// Replace the Button action:
Button(
action: onClose,
label: {
Image(systemName: "xmark")
.font(.system(size: 11))
.foregroundColor(.secondary)
}
)
.buttonStyle(.plain)
.help("Close popover")
// In AppDelegate.mainView(), pass:
PopoverHeaderView(
systemStats: systemStats,
isAuthenticated: isAuthenticated,
onSelectSettings: onSelectSettings,
onClose: { [weak self] in self?.popover?.performClose(nil) }
)
AI Fix Prompt
Fix the following Low (Bug) issue in `Sources/RunnerBar/PopoverMainView.swift` at lines 168-174:

Problem: The close (✕) button in PopoverHeaderView calls NSApplication.shared.hide(nil), which hides the entire application (equivalent to Cmd+H). For a menu bar app, the expected behaviour of a close/dismiss button is to close the popover only. The correct call is popover.performClose(nil) or going through a callback. Since PopoverHeaderView doesn't have a reference to the popover, the cleanest fix is to pass an onClose callback from AppDelegate, similar to how onSelectSettings is passed.

Suggested fix:
// In PopoverHeaderView, add:
let onClose: () -> Void

// Replace the Button action:
Button(
    action: onClose,
    label: {
        Image(systemName: "xmark")
            .font(.system(size: 11))
            .foregroundColor(.secondary)
    }
)
.buttonStyle(.plain)
.help("Close popover")

// In AppDelegate.mainView(), pass:
PopoverHeaderView(
    systemStats: systemStats,
    isAuthenticated: isAuthenticated,
    onSelectSettings: onSelectSettings,
    onClose: { [weak self] in self?.popover?.performClose(nil) }
)

Circle()
.fill(jobDotColor(for: job))
.frame(width: 8, height: 8)
var body: some 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.

🔵 RunnersListView filters to busy-only but displays empty state misleadingly when all runners are idle

RunnersListView now filters runners to only those that are busy (actively running a job). When all runners are idle, the filtered list is empty and the section either disappears entirely or shows an empty state. A user seeing no runners listed may incorrectly conclude that no runners are connected or that the fetch failed, rather than understanding that all runners are idle. The previous behaviour showed all runners. At minimum, the empty state should clarify 'No busy runners — all runners idle' rather than showing nothing or a generic empty message.

Suggested change
var body: some View {
// In RunnersListView, after filtering:
let busyRunners = runners.filter { $0.busy }
if busyRunners.isEmpty {
Text("All runners idle")
.font(.caption)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 4)
} else {
ForEach(busyRunners) { runner in
RunnerRowView(runner: runner)
}
}
AI Fix Prompt
Fix the following Low (UX) issue in `Sources/RunnerBar/PopoverMainView.swift` at lines 220-235:

Problem: RunnersListView now filters runners to only those that are busy (actively running a job). When all runners are idle, the filtered list is empty and the section either disappears entirely or shows an empty state. A user seeing no runners listed may incorrectly conclude that no runners are connected or that the fetch failed, rather than understanding that all runners are idle. The previous behaviour showed all runners. At minimum, the empty state should clarify 'No busy runners — all runners idle' rather than showing nothing or a generic empty message.

Suggested fix:
// In RunnersListView, after filtering:
let busyRunners = runners.filter { $0.busy }
if busyRunners.isEmpty {
    Text("All runners idle")
        .font(.caption)
        .foregroundColor(.secondary)
        .frame(maxWidth: .infinity, alignment: .center)
        .padding(.vertical, 4)
} else {
    ForEach(busyRunners) { runner in
        RunnerRowView(runner: runner)
    }
}

.resolvingSymlinksInPath.path
.resolvingSymlinksInPath().path
guard resolvedDir.hasPrefix(homeDir) else {
DispatchQueue.main.async {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Removed security comment for path-traversal guard reduces future maintainability

The diff removes the comment block explaining WHY the homeDir path-traversal guard exists ('A freeform path like ~/../../usr/local/bin could otherwise cause an arbitrary executable to be launched with the user's privileges'). The guard itself is preserved, which is good. But without the comment, a future maintainer might remove the guard thinking it is overly cautious. The comment is the only documentation of this security rationale.

Suggested change
DispatchQueue.main.async {
// Security: validate that installDir resolves inside the user's home directory
// before executing config.sh there. A path like ~/../../usr/bin could otherwise
// launch an arbitrary executable with the user's privileges.
let homeDir = FileManager.default.homeDirectoryForCurrentUser
.resolvingSymlinksInPath().path
AI Fix Prompt
Fix the following Nit (Security) issue in `Sources/RunnerBar/AddRunnerSheet.swift` at lines 161-164:

Problem: The diff removes the comment block explaining WHY the homeDir path-traversal guard exists ('A freeform path like ~/../../usr/local/bin could otherwise cause an arbitrary executable to be launched with the user's privileges'). The guard itself is preserved, which is good. But without the comment, a future maintainer might remove the guard thinking it is overly cautious. The comment is the only documentation of this security rationale.

Suggested fix:
// Security: validate that installDir resolves inside the user's home directory
// before executing config.sh there. A path like ~/../../usr/bin could otherwise
// launch an arbitrary executable with the user's privileges.
let homeDir = FileManager.default.homeDirectoryForCurrentUser
    .resolvingSymlinksInPath().path

eonist added 3 commits May 10, 2026 14:37
…dth up to 540

1. InlineJobsView filter changed from 'conclusion == nil' (all non-done)
   to 'status == in_progress' only. Per spec #178 active mode: only jobs
   that are actively running appear as inline child rows. Queued jobs
   that haven't started yet are NOT shown inline — they have no step
   data and no meaningful progress to display.

2. AppDelegate maxWidth raised to 540, fixedWidth becomes idealWidth
   fallback. fittingSize drives actual width so content sets its own
   width up to the cap, reducing truncation.
… pushed out of view

Splits PopoverMainView into a pinned header block (system stats + runners
+ rate-limit banner) and a ScrollView body for the actions list.
The ScrollView is capped at maxBodyHeight (maxHeight - headerHeight = 500)
so it never overflows the popover window. The header is always visible.
Removes the no-ScrollView restriction from the regression guard — that
rule applied to fittingSize-driven sizing, which is no longer needed for
the scrollable section since its height is fixed by the cap.
… inline jobs re-render on actions update

- Root frame: .frame(idealWidth: 420, alignment: .top) only — NO maxWidth: .infinity.
  maxWidth: .infinity caused fittingSize.width to return the window width (~540) instead
  of content width (~420), making navigate() resize the popover and shift it sideways.
- onChange(of: store.actions): only reset visibleCount when it has been paged beyond
  default (>10) so poll-driven job enrichment updates (empty→populated jobs) don't
  disrupt inline job rendering. ActionRowView already re-evaluates inlineJobs reactively
  from the new actions value passed down through the ForEach — no extra state needed.
- Update REGRESSION GUARD comment: correct RULE 1 to match actual correct frame contract.
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 10, 2026

CodeAnt AI is running Incremental review

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

codeant-ai Bot commented May 10, 2026

CodeAnt AI Incremental review completed.

eonist added 3 commits May 10, 2026 15:12
… side-jump + inline jobs)

AppDelegate.navigate() was rewritten on this branch to call setFrameSize + contentSize
on every navigation. That is the direct cause of the side-jump: the popover resizes
on every rootView swap and shifts its anchor position. main branch is the proof:
navigate() does ZERO sizing — it is a rootView swap only. Restored exactly.

openPopover() now defers fittingSize read one async tick (matching main's safe contract)
so SwiftUI has a full layout pass before we set contentSize.

PopoverMainView: removed the ScrollView wrapper around ActionsListView. The ScrollView
with .frame(maxHeight:500) clips inline job rows that haven't been laid out yet at the
time fittingSize is read, causing them to appear empty. Without ScrollView the popover
sizes naturally to its content via fittingSize at open — exactly as main does.
Root frame restored to .frame(idealWidth: 420, maxWidth: .infinity, alignment: .top)
matching main branch contract (maxWidth:.infinity is correct when navigate() never
resizes — it does not affect fittingSize when there is no active resize pass).

Pagination: visibleCount reset only when >10, preserving inline job rows on poll ticks.
…es mis-positioned popover)

fittingSize must be read synchronously before pop.show() so the popover
anchors correctly to the status bar button. Wrapping in DispatchQueue.main.async
caused pop.show() to fire with the wrong contentSize, positioning the popover
at the top-left of the screen instead of below the menu bar icon.

Restored to match main branch contract exactly:
  1. fittingSize read synchronously
  2. setFrameSize + contentSize set before pop.show()
  3. pop.show() called immediately after in the same block
  4. navigate() remains a zero-size rootView swap only, forever.

❌ NEVER wrap openPopover() sizing in DispatchQueue.main.async
…e exactly

The @mainactor annotation on openPopover() causes the method to hop to the
main actor asynchronously when called from the @objc togglePopover(), breaking
the synchronous sizing contract. pop.show() fires before contentSize is set,
anchoring the popover at the wrong screen position.

Fix: match main branch class structure exactly:
- final class NSObject (no @unchecked Sendable)
- private let observable (no @mainactor lazy var)
- no @mainactor on any view factory
- no @mainactor on openPopover() or togglePopover()

Feature-specific additions retained: idealWidth 420, maxWidth/maxHeight caps,
enrichGroupIfNeeded, async action group enrichment.
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 10, 2026

CodeAnt AI is running Incremental review

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

codeant-ai Bot commented May 10, 2026

CodeAnt AI Incremental review completed.

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 10, 2026

CodeAnt AI is running Incremental review

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

codeant-ai Bot commented May 10, 2026

CodeAnt AI Incremental review completed.

@eonist eonist force-pushed the feature/296-popover-main-view-redesign branch from 1edb98c to 1d56db5 Compare May 10, 2026 19:29
#370)

Under Architecture 1 (sizingOptions = .preferredContentSize), an uncapped
ScrollView reports its full scroll content height as the SwiftUI ideal height.
NSHostingController publishes this as preferredContentSize.height, causing
NSPopover to re-anchor from scratch on every navigation — side-jump.

Fix: add .frame(maxHeight: NSScreen.main.map { $0.visibleFrame.height * 0.75 } ?? 600)
to the ScrollView in each of the three drill-down views.

Files changed:
- ActionDetailView.swift — ScrollView wrapping jobs ForEach
- JobDetailView.swift   — ScrollView wrapping steps ForEach
- StepLogView.swift     — ScrollView wrapping log Text

Architecture 1 contract preserved:
- sizingOptions = .preferredContentSize unchanged
- No contentSize / setFrameSize calls added
- PopoverMainView root .frame(idealWidth: 420) unchanged
- onLogLoaded / two-hop mechanism removed (not needed under Arch 1)
@sonarqubecloud
Copy link
Copy Markdown

@eonist eonist merged commit 606ded6 into main May 10, 2026
4 of 5 checks passed
@eonist eonist deleted the feature/296-popover-main-view-redesign branch May 10, 2026 20:55
This was referenced May 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL This PR changes 500-999 lines, ignoring generated files

Projects

None yet

1 participant