You are an experienced, pragmatic software engineering AI agent. Do not over-engineer a solution when a simple one is possible. Keep edits minimal. If you want an exception to ANY rule, you MUST stop and get permission first.
PR Buddy is a lightweight, cross-platform system tray desktop app that monitors your GitHub pull requests and sends native OS notifications for key state changes (checks failed, merge queue removal, merges, checks passed). Clicking the tray icon opens a compact panel listing your active PRs from the last 14 days, grouped by status.
Architecture: Tauri v2 desktop app — a Rust backend handles GitHub API polling, OAuth, event diffing, and notifications; a Svelte 5 frontend renders the tray panel UI. Communication between layers uses Tauri IPC commands and events.
| Layer | Technology | Version |
|---|---|---|
| App shell | Tauri | v2.10 |
| Frontend | Svelte | v5 (runes syntax) |
| Frontend lang | TypeScript | strict mode |
| Styling | Tailwind CSS | v3 |
| Build tool | Vite | v6 |
| Backend | Rust | edition 2021, requires 1.77+ |
| HTTP client | reqwest | 0.12 |
| Auth | GitHub OAuth Device Flow | — |
| API | GitHub GraphQL | search + PR fragments |
| Notifications | tauri-plugin-notification | 2.3 |
| Secure storage | tauri-plugin-stronghold | 2.3 |
pr-buddy/
├── src/ # Svelte 5 frontend
│ ├── App.svelte # Root component (auth routing + event listeners)
│ ├── main.ts # Entry point, mounts App
│ ├── lib/
│ │ ├── types.ts # TS interfaces mirroring Rust models (snake_case)
│ │ ├── stores.ts # groupPrs() + shared writable stores
│ │ ├── AuthScreen.svelte # GitHub Device Flow login UI
│ │ ├── PRPanel.svelte # Main panel with grouped sections
│ │ ├── PRSection.svelte # Collapsible section group
│ │ ├── PRCard.svelte # Individual PR row
│ │ ├── SettingsPage.svelte # Notification/theme/repo visibility settings
│ │ ├── UpdateDialog.svelte # In-app updater UI
│ │ ├── TitleBar.svelte # Custom frameless title bar controls
│ │ ├── StatusBadge.svelte # Color-coded status dot
│ │ └── theme.svelte.ts # Global theme preference + dark-mode toggling
│ ├── __mocks__/ # Tauri API stubs for vitest/jsdom
│ └── styles/
│ └── app.css # Tailwind directives + dark theme overrides
├── src-tauri/ # Rust backend
│ ├── Cargo.toml
│ ├── tauri.conf.json # Window config, CSP, tray, bundle icons
│ ├── capabilities/default.json # Tauri v2 ACL permissions
│ ├── build.rs
│ ├── icons/ # App + tray icons (generated via scripts/)
│ └── src/
│ ├── main.rs # Desktop entry: calls lib::run()
│ ├── lib.rs # Tauri setup: plugins, tray, commands, poller
│ ├── models.rs # Shared data types (PullRequest, PrState, etc.)
│ ├── state.rs # AppState (Mutex-wrapped shared state)
│ ├── auth.rs # Device Flow OAuth + token persistence
│ ├── github.rs # GraphQL client + Tauri commands
│ ├── menu.rs # Native tray menu construction + section grouping
│ ├── poller.rs # Background adaptive polling loop
│ ├── notifications.rs # Event diffing + OS notification dispatch
│ ├── settings.rs # User settings load/save + IPC commands
│ ├── updater.rs # Update check/install commands + events
│ └── avatars.rs # Avatar fetch/cache for tray menu icons
├── scripts/
│ ├── smoke-test.sh # Vite dev import-resolution smoke test
│ ├── check_pr_reviews.sh # CI helper: unresolved review thread check
│ ├── check_codex_comments.sh # CI helper: Codex approval/comment status
│ ├── wait_pr_checks.sh # Poll helper: wait until checks/mergeability pass
│ ├── wait_pr_codex.sh # Poll helper: wait for Codex final signal
│ └── generate_icons.py # Python icon generator (cairosvg + Pillow)
├── package.json
├── Makefile # install/dev/build/check/test/smoke/ci/clean targets
├── vite.config.ts
├── vitest.config.ts # Test config (jsdom + Tauri mocks)
├── svelte.config.js
├── tailwind.config.js
├── postcss.config.js
└── tsconfig.json
src-tauri/src/lib.rs— App entry point. Registers plugins, tray, IPC commands, menu events, and starts the poller.src-tauri/src/models.rs+src/lib/types.ts— Canonical cross-layer types. Keep fields and enum values in sync.src-tauri/src/menu.rs+src/lib/stores.ts— Two implementations of PR grouping (tray menu + Svelte panel). Keep section logic aligned.src-tauri/src/auth.rs— GitHub Device Flow plus persisted token load/save/delete.src-tauri/src/settings.rs— User settings storage (settings.json) and settings commands.src-tauri/src/updater.rs+src/lib/UpdateDialog.svelte— Update check/install flow and download progress events.src-tauri/src/poller.rs— Adaptive polling (30s active / 120s idle) plus periodic update checks.src-tauri/src/notifications.rs—diff_pr_states()and notification dispatch (gated by settings).src-tauri/tauri.conf.json— Hidden-by-default tray window (380×520), CSP allowlist, updater endpoint config.src/App.svelte— Root runtime: auth bootstrap, event listeners (prs-updated,auth-cleared,open-settings), and view routing.
All commands are registered in lib.rs via invoke_handler. Frontend calls them with invoke() from @tauri-apps/api/core:
| Command | Module | Returns |
|---|---|---|
start_device_flow_cmd |
auth.rs | DeviceCodeResponse |
poll_for_token_cmd |
auth.rs | bool |
logout_cmd |
auth.rs | () |
is_authenticated_cmd |
auth.rs | bool |
get_pull_requests_cmd |
github.rs | Vec<PullRequest> |
get_user_info_cmd |
github.rs | Option<GitHubUser> |
refresh_prs_cmd |
github.rs | Vec<PullRequest> |
get_settings_cmd |
settings.rs | UserSettings |
save_settings_cmd |
settings.rs | () |
check_for_update_cmd |
updater.rs | UpdateCheckResult |
install_update_cmd |
updater.rs | () |
| Event | Direction | Payload |
|---|---|---|
prs-updated |
Rust → Frontend | PullRequest[] |
auth-cleared |
Rust → Frontend | () |
open-settings |
Rust → Frontend | () |
update-download-progress |
Rust → Frontend | { chunk_length, content_length } |
The system tray requires libayatana-appindicator3. Install it before running make dev or the release binary:
| Distro | Command |
|---|---|
| Arch / Manjaro | sudo pacman -S libayatana-appindicator |
| Ubuntu / Debian | sudo apt install libayatana-appindicator3-dev |
| Fedora | sudo dnf install libayatana-appindicator-gtk3 |
The .deb and .rpm bundles declare this as a package dependency so it installs automatically when users install through those package formats. Arch/pacman users must install it manually.
# Install frontend dependencies (required before any other command)
npm install # or: make install
# Full desktop dev loop (Tauri backend + Vite frontend)
make dev # or: npm run dev
# Frontend-only dev server (no Rust compilation)
npm run vite:dev
# Production builds
make build # or: npm run build
npm run vite:build # frontend-only production bundle
# Type-check + tests
make check # or: npm run check
make test # or: npm run test
make smoke # or: npm run smoke
# Rust checks (run when touching src-tauri)
(cd src-tauri && cargo check)
# Formatting
(cd src-tauri && cargo fmt)
# Frontend: no dedicated formatter script is configured yet.
# Linting
(cd src-tauri && cargo clippy -- -D warnings)
# Frontend: no ESLint script is configured yet.
# CI shortcut (check + test + smoke + build)
make ci
# PR workflow helper scripts
./scripts/check_pr_reviews.sh <pr_number>
./scripts/check_codex_comments.sh <pr_number>
./scripts/wait_pr_checks.sh <pr_number>
./scripts/wait_pr_codex.sh <pr_number>
# Regenerate app and tray icons (requires Python 3, cairosvg, Pillow)
python3 scripts/generate_icons.py
# Clean build artifacts
make cleanImportant: npm run dev and npm run build invoke tauri dev/tauri build, which internally run npm run vite:dev/npm run vite:build via beforeDevCommand/beforeBuildCommand in tauri.conf.json. Do not change the top-level dev/build scripts to call Vite directly — that breaks the Tauri build chain. Do not point beforeBuildCommand at npm run build — that creates an infinite recursion loop.
Rust models in src-tauri/src/models.rs use #[serde(rename_all = "lowercase")] for enums. TypeScript interfaces in src/lib/types.ts must use matching snake_case field names and lowercase string union values:
// Rust (models.rs)
#[serde(rename_all = "lowercase")]
pub enum PrState { Open, Closed, Merged }// TypeScript (types.ts)
export type PrState = "open" | "closed" | "merged";When adding a field to PullRequest, update both models.rs and types.ts.
PR grouping logic exists in two places:
src/lib/stores.ts(groupPrs()for the Svelte panel)src-tauri/src/menu.rs(group_prs()for the native tray menu)
When adding/removing/renaming a section, update both implementations in the same change.
Also update assertions in src/lib/components.test.ts so section behavior stays covered.
This app has two UI surfaces:
- Native tray menu (Rust):
src-tauri/src/menu.rs - Svelte webview panel/windows:
src/App.svelte+src/lib/*.svelte
When a user asks for a "UI", "menu", "section", or "status" change without naming a surface, assume they mean the native tray menu first. Most day-to-day usability is in the tray.
Only treat a request as webview-only when the user explicitly references panel/window behavior (for example: settings page, updater dialog, title bar, Auth screen, PR cards).
If both surfaces are plausible, either:
- ask a clarifying question, or
- state the assumption explicitly and implement tray-first.
Rust commands use #[tauri::command] and return Result<T, E> where E is serializable (AuthError for auth/github commands, String for settings/updater). Commands that need shared state access it via State<'_, AppState>:
#[tauri::command]
pub async fn my_command(state: State<'_, AppState>) -> Result<MyData, AuthError> {
let token = state.token.lock().unwrap();
// ...
}New commands must be added to the invoke_handler macro in lib.rs.
This project uses Svelte 5 runes syntax ($state, $derived, $effect, $props), not Svelte 4 stores in components. The src/lib/stores.ts file uses classic writable() stores for backward compat, but components use runes directly.
Use @lucide/svelte (the Svelte 5 package), not lucide-svelte (Svelte 4 only).
Always use deep imports — barrel imports cause Vite dev server resolution failures:
// ✅ Correct — deep import
import Bell from "@lucide/svelte/icons/bell";
// ❌ Wrong — barrel import, breaks Vite dev
import { Bell } from "@lucide/svelte";
// ❌ Wrong — old Svelte 4 package
import { Bell } from "lucide-svelte";Icon names are kebab-case: bell, x-circle, rotate-ccw, check-circle, file-edit, git-merge, log-out, party-popper, refresh-cw, etc.
When adding a new icon import, also add it to the optimizeDeps.include array in vite.config.ts so Vite pre-bundles it for dev.
Svelte 5's onMount does not accept async callbacks that return cleanup functions. Use a synchronous onMount callback and call a separate async function init() inside:
onMount(() => {
void init(); // fire-and-forget async
});
onDestroy(() => { /* cleanup */ });Do not write onMount(async () => { ... return cleanup; }).
- Do not change
npm run dev/npm run buildto call Vite. These must calltauri dev/tauri build. Frontend-only scripts arevite:dev/vite:build. - Do not update PR section grouping in only one layer.
src/lib/stores.tsandsrc-tauri/src/menu.rsmust stay in sync, andsrc/lib/components.test.tsshould be updated when section behavior changes. - Do not assume ambiguous UX requests target the Svelte webview. Default to tray-menu changes unless the user clearly asks for panel/window behavior.
- Do not pass arguments to
TrayIconBuilder::new(). Tauri v2.10 takes zero arguments. Use.icon()to set the icon separately. - Do not use
asynconMountcallbacks that return cleanup functions in Svelte 5 — it causes type errors. See the pattern above. - Do not use the bundle identifier
com.prbuddy.app. The.appsuffix conflicts with macOS bundle extensions. Current identifier:com.prbuddy.dev. - Do not commit
node_modules/,dist/, orsrc-tauri/target/. These are in.gitignore.
| Tool | Purpose |
|---|---|
| Vitest | Test runner (uses Vite for transforms) |
| @testing-library/svelte | Component rendering in jsdom |
| jsdom | Browser environment for Node |
| scripts/smoke-test.sh | Dev server import validation |
Every Svelte component has a basic render test that imports it, mounts it with mock props, and verifies it produces output. This catches:
- Broken import paths (e.g., wrong icon package)
- Missing exports
- Render crashes from bad props
Run with make test or npm run test.
Starts the Vite dev server, fetches every entry module, and checks for "Failed to resolve import" errors in the server log. This catches issues that vite build and svelte-check miss — specifically, import resolution differences between Vite's build and dev modes (e.g., the "svelte" export condition in @lucide/svelte).
Run with make smoke or npm run smoke.
Components import Tauri APIs (@tauri-apps/api/core, @tauri-apps/plugin-opener, etc.) which don't exist in a jsdom environment. The vitest.config.ts aliases these to stub modules in src/__mocks__/ that return sensible defaults.
When adding a new Tauri plugin import to a component, add a corresponding mock in src/__mocks__/ and alias it in vitest.config.ts.
make ci runs the full validation suite in order: check → test → smoke → build. Run this before pushing.
This project uses type: message commit format. Read git log --oneline for examples:
feat: add Rust backend modules for PR monitoring
fix: bump vite plugin svelte to v5 for vite 6
chore: ignore Cargo.lock
Common prefixes: feat, fix, chore, refactor, docs.
- Run
make ci— this runs type-check, unit tests, smoke test, and production build in sequence. - Alternatively, run them individually:
npm run check,npm run test,npm run smoke,npm run vite:build. - If Rust code changed and Rust 1.77+ is available, run
(cd src-tauri && cargo fmt && cargo clippy -- -D warnings && cargo check). - Do not push to
origin/mainororigin/masterdirectly. - Branch names must be prefixed with
mike/(e.g.,mike/fix-tray-click). - Do not claim imports or dependencies work without running
make smoke—vite buildandsvelte-checkdo not catch all import resolution errors that appear in the Vite dev server.
- Summarise what changed and why.
- List files modified by category (Rust backend / Svelte frontend / config).
- Note any build verification results (
npm run check,npm run vite:build,cargo check).
Every PR with Codex reviews enabled must have zero unresolved review comments before merging. Follow this workflow:
- After pushing, poll for new Codex review comments (
gh api repos/{owner}/{repo}/pulls/{number}/comments). - For each new comment, fix the issue in code, commit, and push.
- Reply to each comment explaining the fix, then resolve the review thread via the GraphQL
resolveReviewThreadmutation. - After pushing fixes, re-request a review by commenting
@codex reviewon the PR (gh pr comment {number} --body "@codex review"). Do not rely on the push alone to trigger a new review pass. - Wait deterministically for Codex to confirm. Poll the PR timeline (~2 min intervals) until Codex signals approval — either a 👍 reaction on your comment, an approving review (
APPROVEDstate), or a message indicating no issues remain. Do not proceed on a timeout or silence; you must receive an explicit positive signal. - If Codex posts new comments instead of approving, go back to step 2 and repeat.
- Only consider the PR ready to merge when Codex has explicitly approved and all review threads are resolved.