Skip to content

feat(panel): reorder usage panel tabs by drag - #72

Merged
choi138 merged 3 commits into
mainfrom
feat/panel-tab-drag-reorder
Aug 6, 2026
Merged

feat(panel): reorder usage panel tabs by drag#72
choi138 merged 3 commits into
mainfrom
feat/panel-tab-drag-reorder

Conversation

@choi138

@choi138 choi138 commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Summary

Panel tab order was fixed to PanelTab.allCases. This lets the user drag tabs sideways in the tab bar and persists the result across launches.

Gesture-based reordering rather than .draggable: the panel is a borderless, nonactivating NSPanel whose local monitor dismisses on outside mouse-down, and a system drag session behaves unpredictably on that window type. A drag started inside the panel never dismisses it.

Implementation

  • PanelTab moves out of UsagePanelView.swift into its own file and gains a String raw value for persistence.
  • PanelTabReordering holds the ordering math as pure functions — slot placement, and the threshold separating a click from a drag — so both are unit tested.
  • UsagePanelSettings persists tabOrder under usagePanel.tabOrder, reusing the defensive normalization already used for reader settings: unknown raw values and duplicates are dropped, and tabs missing from a stored order are appended so a shipped tab can never disappear.
  • Settings gains a Tab order → Reset row, disabled while the order is already the default.

The row is never re-ordered mid-drag

ForEach stays on the committed order and rearrangement is expressed purely through offsets. Reordering the ForEach while a drag is live moves a child inside its container, which tears down that child's gesture — one drag gets split into several, each committing its own reorder. Symptom: the dragged tab detaches from the cursor and the order changes several times per drag.

The dragged tab therefore keeps its slot and tracks the pointer with the raw translation; every other tab slides to the slot the preview order assigns it. The spring animation applies only to the tabs making room.

Verification

  • 500 unit tests pass; swiftformat --lint and swiftlint --strict clean.

  • Driven in the running app with synthetic events, capturing the drag frame by frame:

    frame cursor (window-relative x) dragged tab centre
    5 181 182
    8 256 256

    A single drag crosses the whole bar without breaking, the drop commits, and the stored order survives a relaunch. Click-to-select and the Reset row were exercised the same way.

Trade-off

Replacing the Button with a gesture drops keyboard operation of the tab bar (Full Keyboard Access). VoiceOver keeps a labelled action and the selected trait. This was chosen deliberately over layering a drag on a Button, where the button action races the gesture's onEnded on mouse-up.

Summary by CodeRabbit

  • 새로운 기능

    • 사용량 패널의 탭을 드래그하여 원하는 순서로 재배치할 수 있습니다.
    • 설정의 Display 섹션에서 현재 탭 순서를 확인하고 기본 순서로 초기화할 수 있습니다.
    • 사용자 지정 탭 순서가 저장되어 앱을 다시 실행해도 유지됩니다.
  • 버그 수정

    • 잘못되거나 중복된 탭 설정을 자동으로 정리하고 누락된 탭을 보완합니다.

Tab order was fixed to the declared case order. Let the user drag tabs
sideways in the tab bar and persist the result.

The row keeps its committed order while a drag is live and rearrangement
is expressed through offsets only. Reordering the ForEach mid-drag moves
a child inside its container, which tears down that child's gesture and
splits one drag into several, each committing its own reorder.

Ordering math lives in PanelTabReordering so slot placement and the
threshold that separates a click from a drag can be unit tested.
Persistence reuses the defensive normalization already used for reader
settings, so unknown or missing stored tabs fall back to the declared
order.

Keyboard operation of the tab bar is lost with the Button removed;
VoiceOver keeps a labelled action and the selected trait.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

사용량 패널에 6개 탭의 순서를 저장하고 복원하는 기능을 추가했습니다. 탭 바는 드래그로 탭을 재정렬합니다. 설정 화면은 현재 순서를 표시하고 기본 순서로 초기화합니다. 재정렬 계산과 설정 영속화 테스트도 추가했습니다.

Changes

사용량 패널 탭 순서

Layer / File(s) Summary
탭 모델과 순서 영속화
Toki/Features/UsagePanel/PanelTab.swift, Toki/Features/UsagePanel/UsagePanelSettings.swift, TokiTests/UsagePanelTabOrderSettingsTests.swift, Toki.xcodeproj/project.pbxproj
PanelTab이 6개 탭의 식별자와 표시 정보를 정의합니다. UsagePanelSettings는 탭 순서를 UserDefaults에 저장하고, 중복·알 수 없는 값·누락된 탭을 정규화합니다.
재정렬 계산과 드래그 상호작용
Toki/Features/UsagePanel/PanelTabReordering.swift, Toki/Features/UsagePanel/PanelTabBarView.swift, TokiTests/PanelTabReorderingTests.swift, Toki.xcodeproj/project.pbxproj
탭 바가 프레임과 드래그 위치를 사용해 미리보기 순서를 계산합니다. 4포인트 이상의 수평 이동을 재정렬로 처리하고, 종료 시 선택 또는 재정렬 콜백을 실행합니다.
사용량 패널 연동과 초기화
Toki/Features/UsagePanel/UsagePanelView.swift, Toki/Features/UsagePanel/PanelSettingsView.swift, Toki.xcodeproj/project.pbxproj
UsagePanelView가 탭 순서를 표시하고 변경 사항을 설정에 저장합니다. PanelSettingsView는 현재 순서를 표시하고 기본 순서 복원 버튼을 제공합니다.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PanelTabBarView
  participant PanelTabReordering
  participant UsagePanelView
  participant UsagePanelSettings
  PanelTabBarView->>PanelTabReordering: 드래그 위치와 탭 프레임 전달
  PanelTabReordering-->>PanelTabBarView: 미리보기 순서 반환
  PanelTabBarView->>UsagePanelView: 새 탭 순서 전달
  UsagePanelView->>UsagePanelSettings: setTabOrder 호출
  UsagePanelSettings-->>UsagePanelView: tabOrder 변경 알림
Loading

Possibly related PRs

  • choi138/toki#23: PanelTab, PanelTabBarView, UsagePanelView의 탭 인프라와 직접 연결됩니다.
  • choi138/toki#63: 같은 탭 바와 사용량 패널 설정 인프라를 변경했습니다.
  • choi138/toki#17: PanelSettingsView, UsagePanelSettings, UsagePanelView의 설정 흐름과 연결됩니다.

Poem

당근을 먹은 토끼가 탭을 살짝 끌어요
네 칸 넘으면 순서가 바뀌어요
저장된 탭은 다시 찾아와요
Reset을 누르면 처음으로 돌아와요
깡충, 패널이 새로 정돈됐어요!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 제목은 사용량 패널 탭을 드래그로 재정렬하는 주요 변경 사항을 정확하고 간결하게 설명합니다.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/panel-tab-drag-reorder

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
Toki/Features/UsagePanel/PanelSettingsView.swift (1)

142-148: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reset 버튼에 접근성 라벨을 추가해 주세요.

버튼 라벨이 "Reset"뿐입니다. VoiceOver 사용자는 무엇을 초기화하는지 알 수 없습니다. 같은 행의 "Tab order" 텍스트는 별도 요소입니다.

♿ 제안 수정
             .buttonStyle(.plain)
             .font(.system(size: 11, weight: .semibold))
             .foregroundColor(Color.white.opacity(settings.isUsingDefaultTabOrder ? 0.3 : 0.7))
             .disabled(settings.isUsingDefaultTabOrder)
+            .accessibilityLabel(Text("Reset tab order"))
🤖 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 `@Toki/Features/UsagePanel/PanelSettingsView.swift` around lines 142 - 148,
Update the “Reset” Button in the tab-order settings row to provide an
accessibility label that clearly identifies it as resetting the tab order, while
preserving the existing visible title and 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 `@Toki/Features/UsagePanel/PanelTabBarView.swift`:
- Around line 79-90: Update the tab item implementation in tabButton(for:) to
use a Button that sets activeTab, preserving the existing tab label content and
applying a plain button style. Attach dragGesture(for:) with simultaneousGesture
rather than replacing the Button gesture, so macOS keyboard focus and
Space/Return activation remain available while drag reordering continues to
work.

---

Nitpick comments:
In `@Toki/Features/UsagePanel/PanelSettingsView.swift`:
- Around line 142-148: Update the “Reset” Button in the tab-order settings row
to provide an accessibility label that clearly identifies it as resetting the
tab order, while preserving the existing visible title and button behavior.
🪄 Autofix

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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c11e7f97-241c-406b-96ae-99fa4e7ed21a

📥 Commits

Reviewing files that changed from the base of the PR and between 0dd8c6e and 3ac7b8a.

📒 Files selected for processing (9)
  • Toki.xcodeproj/project.pbxproj
  • Toki/Features/UsagePanel/PanelSettingsView.swift
  • Toki/Features/UsagePanel/PanelTab.swift
  • Toki/Features/UsagePanel/PanelTabBarView.swift
  • Toki/Features/UsagePanel/PanelTabReordering.swift
  • Toki/Features/UsagePanel/UsagePanelSettings.swift
  • Toki/Features/UsagePanel/UsagePanelView.swift
  • TokiTests/PanelTabReorderingTests.swift
  • TokiTests/UsagePanelTabOrderSettingsTests.swift

Comment on lines +79 to +90
.gesture(dragGesture(for: tab))
.onHover { isHovering in
guard draggingTab == nil else { return }
hoveredTab = isHovering ? tab : nil
}
.help(tab.title)
.accessibilityElement(children: .combine)
.accessibilityLabel(Text(tab.title))
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : [.isButton])
.accessibilityAction {
activeTab = tab
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

탭 바의 키보드 조작이 회귀했습니다.

Button.gesture(dragGesture(for:))로 교체했습니다. 이제 탭 항목은 포커스를 받지 못합니다. 키보드 사용자는 탭을 전환할 수 없습니다. .accessibilityAction은 VoiceOver 액션만 제공하며, 키보드 포커스 이동과 Space/Return 활성화를 제공하지 않습니다.

배포 타깃이 macOS 13.0이므로 onKeyPress는 사용할 수 없습니다. Button으로 탭 항목을 감싸고 드래그는 simultaneousGesture로 처리하는 방식을 검토해 주세요. 재정렬 임계값이 4pt이므로 버튼 탭 인식과 충돌은 작습니다.

♿ 제안 방향 (검증 필요)
-        .contentShape(Rectangle())
-        .background(frameReader(for: tab))
-        ...
-        .gesture(dragGesture(for: tab))
+        .contentShape(Rectangle())
+        .background(frameReader(for: tab))
+        ...
+        // Button 래핑으로 키보드 포커스와 활성화를 복구하고,
+        // 드래그는 동시 제스처로 유지합니다.
+        .simultaneousGesture(dragGesture(for: tab))

tabButton(for:)의 최상위를 Button { activeTab = tab } label: { ... } + .buttonStyle(.plain)로 감싸는 변경이 함께 필요합니다.

🤖 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 `@Toki/Features/UsagePanel/PanelTabBarView.swift` around lines 79 - 90, Update
the tab item implementation in tabButton(for:) to use a Button that sets
activeTab, preserving the existing tab label content and applying a plain button
style. Attach dragGesture(for:) with simultaneousGesture rather than replacing
the Button gesture, so macOS keyboard focus and Space/Return activation remain
available while drag reordering continues to work.

choi138 added 2 commits August 6, 2026 12:52
A press classified only by horizontal travel still activated the tab when
the pointer was dragged vertically off it and released, where the previous
Button cancelled activation on release outside its bounds.

Selection now requires click-sized travel in both axes and a release inside
the tab. An unmeasured tab stays clickable so the row is never dead before
its first layout pass.
Hover callbacks were dropped wholesale while a drag was live, so a tab the
pointer had left kept its hover colors until the cursor crossed it again.

Exit events now still clear the matching tab. Enter events stay suppressed
so tabs sliding under a stationary pointer do not light up mid-drag.
@choi138

choi138 commented Aug 6, 2026

Copy link
Copy Markdown
Owner Author

Codex review (lane-routed, local)

Seven lanes activated: baseline, usage-pricing, remote-sync, concurrency-lifecycle, swiftui-architecture, build-portability, testing.

Scope resolution initially failed closed: .build/ carries SwiftPM dependency checkouts with their own .git directories, which trips the embedded-repository invariant. The review ran in a temporary worktree containing tracked files only, so the working tree was untouched.

Findings

ID Priority Lanes Finding Outcome
F1 P2 baseline, testing Keyboard activation lost with Button removed wont_fix — deliberate trade-off, documented in the PR description
F2 P2 swiftui-architecture, baseline (P3) Press dragged off a tab still selected it on release Fixed in d6f19aa
F3 P2 testing Ordering math assumes a left-to-right layout Left open — unreachable today
F4 P3 swiftui-architecture, concurrency-lifecycle Hover highlight left behind after a drag Fixed in 82ba567

usage-pricing, remote-sync and build-portability were clean.

Fixes

d6f19aa — selection was classified from horizontal travel alone, so pressing a tab, dragging vertically off it and releasing still switched tabs, where the previous Button cancelled activation on release outside its bounds. Selection now requires click-sized travel in both axes and a release inside the tab. The check lives in PanelTabReordering.isSelectionTap with four tests, including one pinning that an unmeasured tab stays clickable before the first layout pass.

82ba567 — hover callbacks were dropped wholesale during a drag, so a tab the pointer had left kept its hover colors until the cursor crossed it again. Exit events now clear the matching tab; enter events stay suppressed so tabs sliding under a stationary pointer do not light up mid-drag.

Each fix was committed separately after swiftformat --lint, swiftlint --strict and the full suite (504 tests).

Re-review

baseline, swiftui-architecture and concurrency-lifecycle re-ran against the fixed commits — all three clean.

Left open

F3 (RTL). reordered sorts by increasing midX and slotMidX accumulates from minX, so a right-to-left layout would place the first logical tab on the right and a short drag could reverse the persisted order. The app ships no .lproj and never reads layoutDirection, so this is unreachable as built. If Toki is ever localized for an RTL language, the tab bar needs pinning to LTR or the placement math needs to become direction-aware.

F1 (keyboard). Replacing the Button with a gesture drops focus and Space/Return activation; VoiceOver keeps a labelled action and the selected trait. The alternative — layering a drag on a Button — races the button action against the gesture's onEnded on mouse-up.

Note on tooling

Five of the seven lanes returned prose instead of the structured schema, so merge_findings.py rejected their output (exit 2) and the findings had to be recovered from stderr. Worth a look at the runner's structured-output path, separately from this PR.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Toki/Features/UsagePanel/PanelTabReordering.swift (1)

54-64: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

RTL 레이아웃의 재정렬 방향을 지원하세요.

reorderedmidX를 오름차순으로 정렬합니다. slotMidX는 슬롯을 왼쪽에서 오른쪽으로 누적합니다. RTL 레이아웃에서는 이 좌표 가정이 시각 순서와 다릅니다. 드래그 미리보기와 저장 순서가 잘못된 슬롯을 사용할 수 있습니다.

LayoutDirection을 전달하거나 좌표를 방향에 맞게 정규화하세요. RTL 이동과 슬롯 위치를 검증하는 테스트도 추가하세요.

Also applies to: 67-90

🤖 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 `@Toki/Features/UsagePanel/PanelTabReordering.swift` around lines 54 - 64,
Update the tab reordering logic around sortedOthers, insertionIndex, and
reordered to account for LayoutDirection: normalize or reverse midX comparisons
so RTL uses visual right-to-left ordering while preserving LTR behavior. Ensure
the same direction-aware ordering is used for drag previews and persisted slot
placement, and add coverage for RTL moves and slot positions.
🤖 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.

Outside diff comments:
In `@Toki/Features/UsagePanel/PanelTabReordering.swift`:
- Around line 54-64: Update the tab reordering logic around sortedOthers,
insertionIndex, and reordered to account for LayoutDirection: normalize or
reverse midX comparisons so RTL uses visual right-to-left ordering while
preserving LTR behavior. Ensure the same direction-aware ordering is used for
drag previews and persisted slot placement, and add coverage for RTL moves and
slot positions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c957180f-0d89-44a2-9645-ade758c6ddc7

📥 Commits

Reviewing files that changed from the base of the PR and between 3ac7b8a and 82ba567.

📒 Files selected for processing (3)
  • Toki/Features/UsagePanel/PanelTabBarView.swift
  • Toki/Features/UsagePanel/PanelTabReordering.swift
  • TokiTests/PanelTabReorderingTests.swift

@choi138

choi138 commented Aug 6, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@choi138
choi138 merged commit b035b34 into main Aug 6, 2026
9 checks passed
@choi138
choi138 deleted the feat/panel-tab-drag-reorder branch August 6, 2026 04:16
choi138 added a commit to choi138/hermes-agent that referenced this pull request Aug 6, 2026
A review comment carries only diff_hunk, the slice around itself, and on a large
diff that slice often excludes the code the comment is actually about. On
choi138/toki#72 the reviewer said a Button had been replaced by a gesture; the
hunk's tail showed the gesture but not the Button, so the advisory could only
answer 정보 부족. The pull request's own file list does contain it —
"-        Button {" is right there in PanelTabBarView.swift's patch.

fetch_pull_files follows the house signature (subject_url, *, repository, limit)
rather than taking a bare pull number, because _subject_coordinates
cross-validates the GitHub-supplied subject URL against the independently
allowlisted repository. Dropping that would let a poisoned notification payload
redirect the read.

An oversized response degrades to unavailable=True instead of raising. _decode_json
rejects a body over _MAX_RESPONSE_BYTES with a *retryable* protocol_error, and
runtime backoff would then retry that poll forever without ever delivering the
notification — a livelock on exactly the large pull requests this read exists to
explain. Wider diff context is best effort, so it degrades.

unavailable is deliberately distinct from an empty files tuple, and a full page is
reported as truncated=True: a reader must never conclude "nothing changed", or
refute a review comment, from evidence it never received. Reading arbitrary
repository files is NOT part of this change — that would let a prompt-injected
review comment choose the path and exfiltrate source into Discord, and it needs
its own path-traversal guard and repository allowlist. This read stays scoped to
the pull request the notification is about.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant