mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/brennanb2025-agent-dashboard-daemon-new-terminals
This commit is contained in:
@@ -1,321 +0,0 @@
|
||||
---
|
||||
name: auto-pr-merge
|
||||
description: Create PR, wait for checks, fix issues iteratively, and merge with --admin
|
||||
---
|
||||
|
||||
# auto-pr-merge
|
||||
|
||||
End-to-end autonomous PR workflow: create PR, wait for CI checks to pass, fix any issues (typecheck, code review), update PR, and merge with admin override. Runs fully autonomously without user confirmation.
|
||||
|
||||
**IMPORTANT**: Execute this entire process autonomously without asking the user for confirmation at any step. Just do it.
|
||||
|
||||
---
|
||||
|
||||
## Process Overview
|
||||
|
||||
```
|
||||
CREATE PR → WAIT FOR CHECKS → FIX LOOP (max 3) → MERGE --admin
|
||||
│ │ │ │
|
||||
/create-pr 1min + 1min polls diagnose CI gh pr merge
|
||||
(up to ~6min) fix issues --admin
|
||||
pn typecheck --squash
|
||||
/review-code --delete-branch
|
||||
commit & push
|
||||
wait for checks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create PR
|
||||
|
||||
Run the `/create-pr` skill to create or update the pull request.
|
||||
|
||||
```
|
||||
Use the Skill tool: skill: "create-pr"
|
||||
```
|
||||
|
||||
After `/create-pr` completes, extract the PR number and URL:
|
||||
|
||||
```bash
|
||||
gh pr view --json number,url --jq '.number, .url'
|
||||
```
|
||||
|
||||
Store the PR number for later use.
|
||||
|
||||
**CRITICAL: DO NOT STOP after /create-pr completes. Continue to Step 2.**
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Wait for PR Checks
|
||||
|
||||
Use the **Check Polling Procedure** (defined below) to wait for CI checks. Based on the result:
|
||||
|
||||
- `PASSED` → Skip to Step 4 (MERGE)
|
||||
- `FAILED` → Proceed to Step 3 (FIX LOOP)
|
||||
- `NO_CHECKS` → Skip to Step 4 (MERGE) — no CI configured, nothing to wait for
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Fix Loop (Max 3 Iterations)
|
||||
|
||||
Run up to **3 iterations** of the diagnose-fix-review-push cycle.
|
||||
|
||||
### 3a. Diagnose Failures
|
||||
|
||||
Identify which checks failed and get their logs:
|
||||
|
||||
```bash
|
||||
# Get the most recent failed run ID for this branch
|
||||
FAILED_RUN=$(gh run list --branch $(git branch --show-current) --limit 5 --json databaseId,conclusion,name --jq '[.[] | select(.conclusion == "failure")] | .[0].databaseId')
|
||||
echo "Failed run: $FAILED_RUN"
|
||||
|
||||
# View failed step logs (truncated to last 200 lines to avoid context bloat)
|
||||
if [ -n "$FAILED_RUN" ] && [ "$FAILED_RUN" != "null" ]; then
|
||||
gh run view "$FAILED_RUN" --log-failed 2>&1 | tail -200
|
||||
fi
|
||||
```
|
||||
|
||||
If `gh run view --log-failed` returns too much output or doesn't work, fall back to:
|
||||
|
||||
```bash
|
||||
gh run view "$FAILED_RUN" --json jobs --jq '.jobs[] | select(.conclusion == "failure") | {name, steps: [.steps[] | select(.conclusion == "failure") | {name, conclusion}]}'
|
||||
```
|
||||
|
||||
### 3b. Fix Issues
|
||||
|
||||
Based on the failure diagnosis:
|
||||
|
||||
1. **Read the error output** from the failed checks
|
||||
2. **Identify the root cause** (build error, lint error, test failure, type error, etc.)
|
||||
3. **Apply fixes** using the Edit tool
|
||||
4. **Delegate to specialized skills when appropriate**:
|
||||
- **Lint errors**: Run `/fix-lint` skill
|
||||
- **Build errors**: Run `/fix-build` skill
|
||||
- **Test failures**: Read failing test, fix the code or test manually
|
||||
|
||||
### 3c. Run Typecheck
|
||||
|
||||
After applying fixes, verify types are clean:
|
||||
|
||||
```bash
|
||||
pn typecheck 2>&1
|
||||
```
|
||||
|
||||
- If typecheck passes: continue to 3d
|
||||
- If typecheck fails: fix type errors and re-run (up to 3 attempts within this step)
|
||||
- Read the error output
|
||||
- Fix the type errors
|
||||
- Re-run `pn typecheck`
|
||||
- If still failing after 3 attempts: move on to 3d anyway (CI will catch remaining issues)
|
||||
|
||||
### 3d. Run Code Review (Quick Round)
|
||||
|
||||
Run **only** `/review-code` to catch any issues introduced by the fixes. Do NOT run `/review-correctness`, `/review-via-codex`, `/review-algorithm-architecture`, or any other review skill — only `/review-code`:
|
||||
|
||||
```
|
||||
Use the Skill tool: skill: "review-code"
|
||||
```
|
||||
|
||||
After review completes:
|
||||
|
||||
- If **Critical or High** issues found: fix them using the Edit tool, then re-run `pn typecheck` to verify the review fixes don't introduce type errors
|
||||
- If only **Medium or Low** issues: acceptable, continue
|
||||
- If **no issues**: continue
|
||||
|
||||
### 3e. Update PR
|
||||
|
||||
Commit and push only if there are actual changes:
|
||||
|
||||
```bash
|
||||
# Check if there are changes to commit
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
git add -A && git commit -m "fix: address CI failures and review feedback"
|
||||
git push --force-with-lease
|
||||
else
|
||||
echo "NO_CHANGES"
|
||||
fi
|
||||
```
|
||||
|
||||
- If `NO_CHANGES`: No fixes were needed/possible. Exit the fix loop and proceed to Step 4.
|
||||
- If changes were pushed: continue to 3f.
|
||||
|
||||
### 3f. Wait for PR Checks Again
|
||||
|
||||
Use the **Check Polling Procedure** (defined below) to wait for CI checks. Based on the result:
|
||||
|
||||
- `PASSED` → Exit fix loop, proceed to Step 4
|
||||
- `FAILED` → Next iteration of fix loop (back to 3a)
|
||||
- `NO_CHECKS` → Exit fix loop, proceed to Step 4
|
||||
|
||||
If max 3 fix iterations reached, proceed to Step 4 anyway.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Merge with --admin
|
||||
|
||||
Merge the PR using admin override with squash merge.
|
||||
|
||||
**IMPORTANT: Worktree detection.** When running from a git worktree, `--delete-branch` will fail because `gh` tries to checkout the default branch locally, but it's already checked out in the main worktree. Detect this and handle accordingly:
|
||||
|
||||
```bash
|
||||
# Check if we're in a worktree (not the main working tree)
|
||||
IS_WORKTREE=false
|
||||
if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]; then
|
||||
IS_WORKTREE=true
|
||||
fi
|
||||
|
||||
BRANCH=$(git branch --show-current)
|
||||
|
||||
if [ "$IS_WORKTREE" = "true" ]; then
|
||||
# In a worktree: merge WITHOUT --delete-branch, then delete remote branch separately
|
||||
gh pr merge --admin --squash
|
||||
# Delete the remote branch manually (local cleanup happens when worktree is removed)
|
||||
git push origin --delete "$BRANCH" 2>/dev/null || true
|
||||
else
|
||||
# Normal repo: use --delete-branch as usual
|
||||
gh pr merge --admin --squash --delete-branch
|
||||
fi
|
||||
```
|
||||
|
||||
- `--admin` bypasses branch protection rules (required reviews, status checks)
|
||||
- `--squash` squashes all commits into one clean commit
|
||||
- `--delete-branch` cleans up the feature branch after merge (skipped in worktrees to avoid checkout conflict)
|
||||
|
||||
If merge fails:
|
||||
|
||||
1. Check the error message
|
||||
2. If the error contains `'master' is already used by worktree` or similar: retry without `--delete-branch` and delete the remote branch manually with `git push origin --delete <branch>`
|
||||
3. If merge conflicts: use Skill tool with `skill: "resolve-conflicts"`, push, then retry merge once
|
||||
4. If other error: report the full error to the user
|
||||
|
||||
---
|
||||
|
||||
## Check Polling Procedure
|
||||
|
||||
This is the shared polling logic used by Step 2 and Step 3f. **Do NOT run this as a single long bash command** — the Bash tool will timeout. Instead, run each poll as a **separate Bash call**.
|
||||
|
||||
**IMPORTANT: Minimize token waste.** Do NOT add verbose commentary between polls. Just run the wait, run the check, and act on the result. No cheerful status messages, no "patience is key", no filler text.
|
||||
|
||||
### Initial wait
|
||||
|
||||
After a push, CI takes time to run. Wait **1 minute** before the first poll:
|
||||
|
||||
```bash
|
||||
echo "Waiting 1 minute for CI checks to run..." && sleep 60
|
||||
```
|
||||
|
||||
**IMPORTANT**: You MUST actually execute the `sleep 60` command and wait for it to complete. Do NOT skip the sleep or claim you waited without running the command. The sleep ensures CI has time to finish.
|
||||
|
||||
**CRITICAL**: When calling the Bash tool for `sleep 60`, you MUST set `timeout: 120000` (2 minutes) on the Bash tool call. Similarly, for `sleep 60` poll interval calls, set `timeout: 120000` (2 minutes).
|
||||
|
||||
### Poll loop (run each poll as a separate Bash call)
|
||||
|
||||
For each poll attempt (1 through 5):
|
||||
|
||||
```bash
|
||||
CHECKS=$(gh pr checks --json name,state,bucket 2>&1)
|
||||
echo "$CHECKS"
|
||||
|
||||
# Parse results using 'bucket' field (pass, fail, pending, skipping, cancel)
|
||||
if echo "$CHECKS" | jq -e 'length == 0' >/dev/null 2>&1; then
|
||||
echo "RESULT:NO_CHECKS"
|
||||
elif echo "$CHECKS" | jq -e '[.[].bucket] | all(. == "pass" or . == "skipping")' >/dev/null 2>&1; then
|
||||
echo "RESULT:PASSED"
|
||||
elif echo "$CHECKS" | jq -e '[.[].bucket] | any(. == "fail" or . == "cancel")' >/dev/null 2>&1; then
|
||||
echo "RESULT:FAILED"
|
||||
else
|
||||
echo "RESULT:PENDING"
|
||||
fi
|
||||
```
|
||||
|
||||
### Decision logic after each poll
|
||||
|
||||
- `RESULT:PASSED` → Return `PASSED`. Stop polling.
|
||||
- `RESULT:FAILED` → Return `FAILED`. Stop polling.
|
||||
- `RESULT:PENDING` → Wait **1 minute**, then poll again (use `timeout: 120000` on the Bash tool call):
|
||||
```bash
|
||||
sleep 60
|
||||
```
|
||||
- `RESULT:NO_CHECKS` → Return `NO_CHECKS` (no CI configured).
|
||||
|
||||
### After 5 polls (timeout)
|
||||
|
||||
If checks are still pending after 5 polls (~6 min total), return `FAILED` to enter the fix loop for investigation.
|
||||
|
||||
### Handling stale checks after re-push (Step 3f only)
|
||||
|
||||
After pushing new commits in Step 3e, old check results may linger briefly. To avoid reading stale results:
|
||||
|
||||
1. Record the latest commit SHA **before pushing**: `OLD_SHA=$(git rev-parse HEAD)`
|
||||
2. After pushing, the first 1-2 polls should verify the check suite is for the **new** commit. If `gh pr checks` still shows the old results (check names match but they completed instantly), wait an extra 30s.
|
||||
3. Alternatively, look for checks with `state: "IN_PROGRESS"` or `"QUEUED"` as a signal that fresh checks have started.
|
||||
|
||||
---
|
||||
|
||||
## Exit Conditions
|
||||
|
||||
- **Success**: PR merged successfully
|
||||
- **Max fix iterations**: After 3 fix loop iterations, attempt merge with --admin regardless
|
||||
- **No changes to fix**: Fix loop produced no changes, merge with --admin
|
||||
- **No CI checks**: Repo has no checks configured, merge immediately
|
||||
- **Unrecoverable error**: PR creation fails, merge fails after retry, or gh CLI issues
|
||||
|
||||
---
|
||||
|
||||
## Progress Tracking
|
||||
|
||||
Display progress after each major step:
|
||||
|
||||
```
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ AUTO PR MERGE ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ Step 1 - Create PR: ✅ PR #123 created ║
|
||||
║ Step 2 - Wait for checks: ❌ 2/5 checks failed ║
|
||||
║ Step 3 - Fix Loop: ║
|
||||
║ Iteration 1/3: ║
|
||||
║ Diagnose: ✅ Type errors in 2 files ║
|
||||
║ Fix: ✅ Fixed type errors ║
|
||||
║ Typecheck: ✅ Passed ║
|
||||
║ Review: ✅ No critical issues ║
|
||||
║ Push: ✅ Updated PR ║
|
||||
║ Checks: ✅ All passed ║
|
||||
║ Step 4 - Merge: ✅ Merged with --admin --squash ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ PR URL: https://github.com/org/repo/pull/123 ║
|
||||
║ Result: Successfully merged! ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Instructions
|
||||
|
||||
1. **DO NOT ask for user confirmation** - Execute the entire workflow autonomously
|
||||
2. **DO NOT stop after /create-pr** - The PR creation is just step 1 of 4
|
||||
3. **DO NOT stop after checks pass** - Must complete the merge step
|
||||
4. **DO NOT run polling as a single long bash loop** - Each poll must be a separate Bash call to avoid the 2-minute Bash timeout. Use `sleep` in its own Bash call between polls.
|
||||
5. **DO wait the full polling period** - Don't skip check waiting; CI needs time
|
||||
6. **DO run typecheck before review** - Catch type errors early
|
||||
7. **DO run /review-code in each fix iteration** - Ensure code quality
|
||||
8. **DO check for actual changes before committing** - Skip commit/push if `git status --porcelain` is empty
|
||||
9. **DO use --admin for merge** - This is intentional to bypass protection rules
|
||||
10. **DO use --squash for merge** - Keep git history clean
|
||||
11. **DO delete branch after merge** - Clean up with --delete-branch
|
||||
12. **Maximum 3 fix iterations** - Don't loop forever; after 3, merge with --admin
|
||||
13. **DO NOT run /review-correctness or /review-via-codex** - This skill only uses `/review-code` for quick checks in the fix loop. Codex-based reviews are slow and belong in `/auto-review-fix`, not here.
|
||||
14. **DO actually execute sleep commands** - When the skill says `sleep 60`, you MUST run the bash command and wait for it to complete. Do not skip or fabricate the wait.
|
||||
15. **DO set timeout on Bash tool for sleep commands** - `sleep 60` requires `timeout: 120000`. The default 2-minute Bash timeout is sufficient for 1-minute sleeps but set it explicitly for safety.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If `/create-pr` fails: Report error and exit
|
||||
- If `gh` CLI is not installed or authenticated: Report and exit
|
||||
- If no CI checks are configured: Skip waiting and merge directly
|
||||
- If check polling times out: Enter fix loop to investigate
|
||||
- If typecheck loops more than 3 times within a single fix iteration: Move on to review
|
||||
- If fix loop produces no changes: Exit loop and merge with --admin
|
||||
- If merge fails due to conflicts: Try `/resolve-conflicts`, push, then retry merge once
|
||||
- If merge fails for other reasons: Report the error to the user with the full error output
|
||||
@@ -1,650 +0,0 @@
|
||||
---
|
||||
name: auto-review-fix
|
||||
description: Automated iterative code review and fix loop with parallel review agents
|
||||
---
|
||||
|
||||
# auto-review-fix
|
||||
|
||||
Automated iterative code review and fix loop. Reviews all code changes on the current branch since it diverged from main, including uncommitted changes (staged and unstaged). Validates findings, fixes issues, and repeats until clean or max iterations reached.
|
||||
|
||||
**IMPORTANT**: Execute this entire process autonomously without asking the user for confirmation at any step. Just do the iterations.
|
||||
|
||||
---
|
||||
|
||||
## Process Overview
|
||||
|
||||
Run up to **4 iterations** of the review-validate-fix cycle. Stop early ONLY when a **follow-up review confirms no issues to fix** remain.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ITERATION LOOP (max 4 rounds) │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 0. SETUP PHASE (first iteration only) │ │
|
||||
│ │ - Fetch diff and create 00-review-context.md │ │
|
||||
│ │ - Categorize files by review area │ │
|
||||
│ │ - Deduplicate file assignments │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 1. PARALLEL REVIEW PHASE │ │
|
||||
│ │ - Spawn Task() subagents for each review area │ │
|
||||
│ │ - Each receives ONLY their relevant files │ │
|
||||
│ │ - All reference shared 00-review-context.md │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 2. COMBINE & DEDUPLICATE │ │
|
||||
│ │ - Aggregate all findings │ │
|
||||
│ │ - Deduplicate across review areas │ │
|
||||
│ │ - Exclude previously-skipped issues │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 3. VALIDATE PHASE (all severities) │ │
|
||||
│ │ - Validate all issues (Critical to Low) │ │
|
||||
│ │ - Skip issues already in "Skipped Issues" list │ │
|
||||
│ │ - Group by file, one agent per file │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 4. FIX PHASE │ │
|
||||
│ │ - Group issues by file (up to 5 per agent) │ │
|
||||
│ │ - Each fix done via Task() with opus │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 5. CHECK EXIT CONDITIONS │ │
|
||||
│ │ - Fixed issues? → MUST run another review │ │
|
||||
│ │ - Review shows no issues? → EXIT │ │
|
||||
│ │ - Iteration 4 reached? → EXIT │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Setup (First Iteration Only)
|
||||
|
||||
### Step 1: Fetch the diff
|
||||
|
||||
```bash
|
||||
# Get the merge base (where the branch diverged from main)
|
||||
git diff $(git merge-base origin/main HEAD)
|
||||
```
|
||||
|
||||
### Step 1.5: Check diff size
|
||||
|
||||
```bash
|
||||
# Count changed files
|
||||
git diff $(git merge-base origin/main HEAD) --stat | tail -1
|
||||
# If >500 files changed, exit with recommendation to split PR
|
||||
```
|
||||
|
||||
If the diff contains more than 500 changed files, **EXIT immediately** with:
|
||||
|
||||
```
|
||||
❌ Diff too large (>500 files). Please split this PR into smaller, focused changes.
|
||||
```
|
||||
|
||||
### Step 2: Create shared context file
|
||||
|
||||
Create `00-review-context.md` in the working directory (see below for format).
|
||||
|
||||
### Step 2.5: Extract changed line ranges
|
||||
|
||||
Parse `git diff $(git merge-base origin/main HEAD) --unified=0` to extract line ranges (`@@ +A,B @@` → lines A to A+B-1). Add to context file below.
|
||||
|
||||
### Step 2 (continued): Context file format
|
||||
|
||||
Create `00-review-context.md` in the working directory with:
|
||||
|
||||
```markdown
|
||||
# Review Context
|
||||
|
||||
## Branch Info
|
||||
|
||||
- Base: origin/main
|
||||
- Current: [branch name]
|
||||
|
||||
## Changed Files Summary
|
||||
|
||||
[List all changed files with their change type: A/M/D]
|
||||
|
||||
## Changed Line Ranges (PR Scope)
|
||||
|
||||
<!-- In scope: issues on these lines OR caused by these changes. Out of scope: unrelated pre-existing issues -->
|
||||
|
||||
| File | Changed Lines |
|
||||
| ------------------ | ---------------- |
|
||||
| [path/to/file1.ts] | [45-67, 120-135] |
|
||||
| [path/to/file2.ts] | [10-25] |
|
||||
|
||||
## Review Standards Reference
|
||||
|
||||
- Follow /review-code standards
|
||||
- Focus on: correctness, security, performance, maintainability
|
||||
- Priority levels: Critical > High > Medium > Low
|
||||
|
||||
## File Categories
|
||||
|
||||
[Categorized list - see below]
|
||||
|
||||
## Skipped Issues (Do Not Re-validate)
|
||||
|
||||
<!-- Issues validated but deemed not worth fixing. Do not re-validate these in future iterations. -->
|
||||
<!-- Format: [file:line-range] | [severity] | [reason skipped] | [issue summary] -->
|
||||
<!-- NOTE: Skips should be RARE - only purely cosmetic issues with no functional impact -->
|
||||
|
||||
[Initially empty - populated during validation phase]
|
||||
|
||||
## Iteration State
|
||||
|
||||
<!-- Updated after each phase to enable crash recovery -->
|
||||
|
||||
Current iteration: 1
|
||||
Last completed phase: Setup
|
||||
Files fixed this iteration: []
|
||||
```
|
||||
|
||||
**IMPORTANT**: The "Skipped Issues" section persists across iterations. However, skips should be RARE - only purely cosmetic issues (naming, JSDoc, import ordering) may be skipped. Functional issues like error handling, type safety, and performance must ALWAYS be fixed.
|
||||
|
||||
### Step 3: Categorize and deduplicate files
|
||||
|
||||
Assign each file to **exactly ONE** file category (no duplicates):
|
||||
|
||||
| Priority | Category | File Patterns |
|
||||
| -------- | -------------- | ---------------------------------------------------------------- |
|
||||
| 1 | Electron/Main | `src/main/`, `src/preload/`, `electron.*` |
|
||||
| 2 | Backend/IPC | `*ipc*`, `*handler*`, `*service*` (in main process) |
|
||||
| 3 | Frontend/UI | `src/renderer/`, `components/`, `*.tsx`, `*.css` |
|
||||
| 4 | Config/Build | `*.config.*`, `package.json`, `tsconfig.*`, `electron-builder.*` |
|
||||
| 5 | Utility/Common | Everything else |
|
||||
|
||||
**Deduplication rule**: A file belongs to the FIRST matching category only.
|
||||
|
||||
**Tiebreaker rule**: If a file path matches multiple categories at different directory depths:
|
||||
|
||||
1. Use the **deepest matching directory** (e.g., `src/main/services/ui/dialog.ts` → Backend/IPC because `services/` is deeper than `main/`)
|
||||
2. If same depth, use priority order (lower number wins)
|
||||
|
||||
### Step 4: Map categories to review commands
|
||||
|
||||
Each file category gets reviewed by multiple specialized review commands:
|
||||
|
||||
| Category | Review Commands |
|
||||
| -------------- | ------------------------------------------------ |
|
||||
| Electron/Main | `/review-code`, `/review-algorithm-architecture` |
|
||||
| Backend/IPC | `/review-code`, `/review-algorithm-architecture` |
|
||||
| Frontend/UI | `/review-code`, `/review-algorithm-architecture` |
|
||||
| Config/Build | `/review-code` |
|
||||
| Utility/Common | `/review-code`, `/review-algorithm-architecture` |
|
||||
|
||||
**Total review agents**: (Categories with files) × (Applicable review commands per category)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Parallel Review
|
||||
|
||||
Spawn review subagents using the Task tool. **CRITICAL**: Use the exact Task() definition format.
|
||||
|
||||
### Review Command Descriptions
|
||||
|
||||
| Command | Focus | Engine |
|
||||
| -------------------------------- | ------------------------------------------------------------------------ | ------ |
|
||||
| `/review-code` | Logical bugs, security issues, TypeScript best practices, error handling | Claude |
|
||||
| `/review-algorithm-architecture` | File organization, module boundaries, performance on hot paths | Claude |
|
||||
|
||||
### Spawn agents by category × review command
|
||||
|
||||
For each (category, review_command) pair where files exist, spawn a Task:
|
||||
|
||||
```
|
||||
Task(
|
||||
subagent_type: "general-purpose",
|
||||
model: "opus", // REQUIRED: opus for quality reviews
|
||||
description: "[REVIEW_COMMAND] for [CATEGORY] files",
|
||||
prompt: """
|
||||
Read 00-review-context.md for branch context and changed line ranges.
|
||||
|
||||
You are running [REVIEW_COMMAND] on [CATEGORY] files.
|
||||
|
||||
Review ONLY these files:
|
||||
[SPECIFIC FILE LIST FOR THIS CATEGORY]
|
||||
|
||||
**SCOPE RESTRICTION**:
|
||||
Report issues on changed lines OR caused by the changes (e.g., broken callers, type mismatches).
|
||||
Do NOT report pre-existing issues unrelated to this PR.
|
||||
For issues outside changed lines, include a "Causal Link" explaining the connection.
|
||||
|
||||
Changed line ranges: [FROM 00-review-context.md]
|
||||
|
||||
Follow the [REVIEW_COMMAND] standards exactly. Focus on issues relevant to that review type.
|
||||
|
||||
Output format for each finding:
|
||||
- File: path/to/file.ts
|
||||
- Line: 42
|
||||
- Severity: Critical|High|Medium|Low
|
||||
- Review Type: [REVIEW_COMMAND]
|
||||
- Causal Link: [required if outside changed lines]
|
||||
- Issue: [description]
|
||||
- Fix: [suggested fix]
|
||||
"""
|
||||
)
|
||||
```
|
||||
|
||||
### Example agent spawning for a PR with Electron, Backend, and Frontend files:
|
||||
|
||||
```
|
||||
# Electron/Main files → 2 agents
|
||||
Task(..., description: "/review-code for Electron/Main files", ...)
|
||||
Task(..., description: "/review-algorithm-architecture for Electron/Main files", ...)
|
||||
|
||||
# Backend/IPC files → 2 agents
|
||||
Task(..., description: "/review-code for Backend/IPC files", ...)
|
||||
Task(..., description: "/review-algorithm-architecture for Backend/IPC files", ...)
|
||||
|
||||
# Frontend/UI files → 2 agents
|
||||
Task(..., description: "/review-code for Frontend/UI files", ...)
|
||||
Task(..., description: "/review-algorithm-architecture for Frontend/UI files", ...)
|
||||
|
||||
Total: 6 parallel review agents
|
||||
```
|
||||
|
||||
**Launch ALL Task() calls in a single message block for true parallelism.**
|
||||
|
||||
**IMPORTANT — WAIT FOR ALL AGENTS**:
|
||||
|
||||
- Do NOT use `run_in_background: true` for any Phase 1 review agent.
|
||||
- Task() calls without `run_in_background` are blocking by default — they return only when the subagent finishes.
|
||||
- **VERIFICATION CHECKPOINT**: Before starting Phase 2, you MUST count the number of Task() results you received. It MUST equal the total number of Task() calls you made. If any result is missing, DO NOT proceed — wait or re-spawn the missing agent.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Combine & Deduplicate Results
|
||||
|
||||
**⛔ GATE CHECK**: Before starting Phase 2, verify you received results from ALL Task() agents spawned in Phase 1. Count: [N agents] = [total]. If any agent result is missing, DO NOT proceed. Log which agent(s) are missing and note it in the final report.
|
||||
|
||||
1. **Collect all subagent outputs** (from all review types)
|
||||
|
||||
2. **Scope filter**: Discard findings outside changed lines that lack a Causal Link. Pass others to validation.
|
||||
|
||||
3. **Deduplicate findings** across review types and categories:
|
||||
- Same file + same line range (within 5 lines) + similar issue = duplicate
|
||||
- Keep the more specific/actionable finding
|
||||
- Merge severity (take highest)
|
||||
- Preserve the review type that caught the issue
|
||||
|
||||
4. **Exclude previously-skipped issues**:
|
||||
- Read the "Skipped Issues" section from `00-review-context.md`
|
||||
- For each new finding, check if it matches a skipped issue (same file + overlapping line range + similar description)
|
||||
- If match found → exclude from validation (already determined not worth fixing)
|
||||
- This prevents re-validating the same low-priority issues every iteration
|
||||
|
||||
5. **Categorize remaining issues**:
|
||||
- 🔴 **Critical** - Must fix
|
||||
- 🟠 **High** - Should fix
|
||||
- 🟡 **Medium** - Consider fixing
|
||||
- 🟢 **Low** - Nice to have
|
||||
|
||||
6. **Create findings report** with all non-skipped issues (all severities), including which review type found each issue
|
||||
|
||||
7. **Severity escalation** (iterations 2+):
|
||||
- If an issue was marked "✅ Fix" in iteration N but reappears in iteration N+1:
|
||||
- Escalate severity by one level: Low → Medium → High → Critical
|
||||
- Add note: "⚠️ Persisted from iteration N - previous fix may have been incomplete"
|
||||
- This ensures recurring issues get higher priority attention
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Validation (All Severities)
|
||||
|
||||
Validate all issues that weren't excluded by the "Skipped Issues" list.
|
||||
|
||||
### Grouping strategy:
|
||||
|
||||
- Group findings by file
|
||||
- One validation agent per file (handles all findings for that file)
|
||||
- Maximum 10 validation agents total
|
||||
|
||||
```
|
||||
Task(
|
||||
subagent_type: "general-purpose",
|
||||
model: "opus", // REQUIRED: opus for accurate validation
|
||||
description: "Validate findings in [filename]",
|
||||
prompt: """
|
||||
Validate these findings for [FILE PATH]:
|
||||
|
||||
[LIST OF 1-N FINDINGS FOR THIS FILE]
|
||||
|
||||
**Changed line ranges**: [FROM 00-review-context.md]
|
||||
|
||||
**SCOPE CHECK (first step)**: Issues on changed lines are in scope. For issues outside changed lines, validate the Causal Link - reject as 🚫 Out of Scope if it's just pre-existing tech debt unrelated to PR changes.
|
||||
|
||||
For each IN-SCOPE finding:
|
||||
1. Read the actual code at the reported location
|
||||
2. Verify the issue actually exists
|
||||
3. Check if the suggested fix is correct
|
||||
4. Determine if it's worth fixing
|
||||
|
||||
Mark each as:
|
||||
- ✅ **Fix** - Valid issue in changed code (default - when in doubt, fix it)
|
||||
- ⏭️ **Skip** - Valid but genuinely not worth fixing (RARE - see strict criteria below)
|
||||
- ❌ **False Positive** - Issue doesn't actually exist
|
||||
- 🚫 **Out of Scope** - Issue exists but is in pre-existing code not changed by this PR
|
||||
|
||||
**STRICT Skip Criteria** - Only skip if ALL of these are true:
|
||||
1. The issue is purely cosmetic/stylistic with zero functional impact
|
||||
2. Fixing it would require changing >50 lines of unrelated code
|
||||
3. The pattern is consistently used throughout the codebase (not just this file)
|
||||
|
||||
**DO NOT SKIP these issues (always fix):**
|
||||
- ❌ Silent error swallowing or empty catch blocks
|
||||
- ❌ Type coercion hacks (e.g., `as unknown as X`, `as any`)
|
||||
- ❌ Missing error handling or error messages
|
||||
- ❌ Redundant code, dead code, or unused variables
|
||||
- ❌ Security issues of any severity
|
||||
- ❌ Race conditions or async issues
|
||||
- ❌ Memory leaks or resource cleanup issues
|
||||
- ❌ Any issue in NEW code (code added in this PR)
|
||||
- ❌ IPC security issues (missing validation, exposed handlers)
|
||||
|
||||
Examples of what CAN be skipped:
|
||||
- Pure naming preferences when existing codebase uses the same style
|
||||
- Adding JSDoc to functions that are already self-documenting
|
||||
- Reordering imports when not violating any lint rules
|
||||
|
||||
For each Skip decision, provide:
|
||||
- File and line range
|
||||
- Severity level
|
||||
- Reason for skipping (must match one of the allowed skip criteria above)
|
||||
- Brief issue summary
|
||||
"""
|
||||
)
|
||||
```
|
||||
|
||||
### After validation: Update Skipped Issues
|
||||
|
||||
For any issues marked as ⏭️ **Skip**, append them to the "Skipped Issues" section in `00-review-context.md`:
|
||||
|
||||
```markdown
|
||||
## Skipped Issues (Do Not Re-validate)
|
||||
|
||||
<!-- Format: [file:line-range] | [severity] | [reason skipped] | [issue summary] -->
|
||||
|
||||
src/utils/helper.ts:42-45 | Low | Stylistic preference | Variable naming convention
|
||||
```
|
||||
|
||||
**This ensures the same issues are not re-validated in subsequent iterations.**
|
||||
|
||||
**Out of Scope** issues are discarded (not tracked). Issues outside changed lines CAN be in scope if caused by PR changes.
|
||||
|
||||
### Early exit check
|
||||
|
||||
After validation completes, check if there are any actionable issues:
|
||||
|
||||
```
|
||||
IF (all_issues_marked_FP_or_Skip):
|
||||
→ Update Skipped Issues list in 00-review-context.md
|
||||
→ Update Iteration State: "Last completed phase: Validation (no actionable issues)"
|
||||
→ EXIT - No actionable issues found
|
||||
→ Do NOT proceed to Phase 4
|
||||
```
|
||||
|
||||
This prevents spawning fix agents when there's nothing to fix.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Fix
|
||||
|
||||
**CRITICAL**: All fixes MUST be done via Task() subagents with opus. No direct edits.
|
||||
|
||||
### Step 1: Commit uncommitted changes first
|
||||
|
||||
```bash
|
||||
git status --porcelain
|
||||
# If output exists:
|
||||
git add -A && git commit -m "WIP: Changes before auto-review fixes"
|
||||
```
|
||||
|
||||
### Step 2: Build the fix manifest from validation output
|
||||
|
||||
**MANDATORY**: Create an explicit mapping from validation results to fix actions.
|
||||
|
||||
For every issue marked "✅ Fix" in validation:
|
||||
|
||||
1. Record: `[file] → [issue description] → [suggested fix]`
|
||||
2. Group by file
|
||||
|
||||
**Traceability requirement**: Every "✅ Fix" issue MUST appear in the fix manifest. Do not:
|
||||
|
||||
- Skip issues because you believe a different fix "solves the root cause"
|
||||
- Substitute your judgment for what validation explicitly said to fix
|
||||
- Rationalize away issues as "will be handled by another fix"
|
||||
|
||||
If validation says "✅ Fix" for file X, spawn a fix agent for file X. Period.
|
||||
|
||||
### Step 3: Group validated issues by file
|
||||
|
||||
- Group up to **5 issues per fix agent** (within same file)
|
||||
- If a file has >5 issues, split into multiple agents
|
||||
- **Maximum 10 fix agents** per iteration
|
||||
- If >10 files need fixes, prioritize by severity (Critical > High > Medium > Low)
|
||||
- Defer lower-priority files to the next iteration
|
||||
|
||||
### Conflict prevention
|
||||
|
||||
To avoid race conditions when multiple agents edit overlapping code:
|
||||
|
||||
- Each file should have **at most ONE fix agent running at a time**
|
||||
- If a file has >5 issues requiring multiple agents, spawn them **sequentially** (not in parallel)
|
||||
- After each fix agent completes for a multi-agent file, verify the file is syntactically valid before spawning the next
|
||||
- Update Iteration State after each agent completes: `Files fixed this iteration: [updated list]`
|
||||
|
||||
### Step 4: Spawn fix subagents
|
||||
|
||||
```
|
||||
Task(
|
||||
subagent_type: "general-purpose",
|
||||
model: "opus", // REQUIRED: opus for quality fixes
|
||||
description: "Fix issues in [filename]",
|
||||
prompt: """
|
||||
Fix the following validated issues in [FILE PATH]:
|
||||
|
||||
Issue 1: [description]
|
||||
- Line: XX
|
||||
- Fix: [suggested fix]
|
||||
|
||||
Issue 2: [description]
|
||||
- Line: YY
|
||||
- Fix: [suggested fix]
|
||||
|
||||
[Up to 5 issues]
|
||||
|
||||
Instructions:
|
||||
1. Read the file first
|
||||
2. Apply each fix using the Edit tool
|
||||
3. Verify fixes don't break syntax
|
||||
4. Report what was fixed
|
||||
"""
|
||||
)
|
||||
```
|
||||
|
||||
### Step 5: Track changes
|
||||
|
||||
Record all files modified for the iteration summary.
|
||||
|
||||
### Step 5.5: Verify fixes don't break build
|
||||
|
||||
After all fix agents complete, run a type check:
|
||||
|
||||
```bash
|
||||
npm run typecheck 2>&1
|
||||
```
|
||||
|
||||
If the type check fails:
|
||||
|
||||
- Log which fix agent(s) likely caused the failure
|
||||
- Continue to Phase 5 - the next iteration's review will catch the type errors as new issues
|
||||
- Update Iteration State: `Build status: FAILED (type errors in [files])`
|
||||
|
||||
### Step 6: Verify fix manifest completeness
|
||||
|
||||
Before proceeding to Phase 5, verify:
|
||||
|
||||
- Every file in the fix manifest had a fix agent spawned
|
||||
- Every "✅ Fix" issue from validation was addressed by a fix agent
|
||||
- No issues were skipped due to assumptions about "root cause" fixes elsewhere
|
||||
|
||||
If any "✅ Fix" issue was not addressed, spawn additional fix agents now.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Exit Conditions
|
||||
|
||||
**CRITICAL LOGIC**: You cannot exit with "no issues" immediately after fixing. Must confirm with another review.
|
||||
|
||||
```
|
||||
IF (fixes_applied_this_iteration > 0):
|
||||
→ MUST run another iteration to confirm fixes work
|
||||
→ Cannot claim "no issues" without verification
|
||||
|
||||
IF (review_found_no_issues_to_fix AND fixes_applied_this_iteration == 0):
|
||||
→ EXIT - Clean (all new findings either fixed or added to Skipped Issues)
|
||||
|
||||
IF (iteration >= 4):
|
||||
→ EXIT - Max iterations reached
|
||||
|
||||
IF (same_issues_persist_for_2_iterations):
|
||||
→ EXIT - Stuck in loop
|
||||
|
||||
OTHERWISE:
|
||||
→ Continue to next iteration
|
||||
```
|
||||
|
||||
**The key insight**: "I fixed all issues" ≠ "There are no issues". Another review round must confirm.
|
||||
|
||||
**Note on convergence**: The loop converges because:
|
||||
|
||||
1. Fixed issues no longer appear in subsequent reviews
|
||||
2. Skipped issues are tracked and excluded from future validation
|
||||
3. Only genuinely new issues trigger additional work
|
||||
|
||||
---
|
||||
|
||||
## Iteration Tracking
|
||||
|
||||
Display progress after each phase:
|
||||
|
||||
```
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ ITERATION 1/4 ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ Phase 0 - Setup: ✅ Context file created ║
|
||||
║ Phase 1 - Review: ✅ 6 agents across 2 review types ║
|
||||
║ /review-code: 3 agents (all categories) ║
|
||||
║ /review-algorithm-architecture: 3 agents ║
|
||||
║ Phase 2 - Combine: ✅ 12 findings (3 out-of-scope, 0 skipped) ║
|
||||
║ Phase 3 - Validate: ✅ 8 fix, 2 skip, 1 out-of-scope, 1 FP ║
|
||||
║ Phase 4 - Fix: ✅ 8 issues fixed (opus) ║
|
||||
║ Phase 5 - Check: 🔁 Fixes applied → verify next round ║
|
||||
║ Skipped Issues: 📝 2 added to tracking list ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
→ Starting iteration 2 to verify fixes...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final Report
|
||||
|
||||
```
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ AUTO REVIEW-FIX COMPLETE ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ Iterations completed: X/4 ║
|
||||
║ Exit reason: [Clean review | Max iterations | Loop stuck] ║
|
||||
║ Total issues found: XX ║
|
||||
║ Out of scope: XX (pre-existing code, not in PR) ║
|
||||
║ Issues fixed: XX ║
|
||||
║ Issues skipped: XX (tracked, won't revalidate) ║
|
||||
║ Issues remaining: XX ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ Review agents by type: ║
|
||||
║ /review-code: X agents ║
|
||||
║ /review-algorithm-architecture: X agents ║
|
||||
║ Fix agents used: X (opus) ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ Issues found by review type: ║
|
||||
║ /review-code: XX issues ║
|
||||
║ /review-algorithm-architecture: XX issues ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ Files modified: ║
|
||||
║ - path/to/file1.ts ║
|
||||
║ - path/to/file2.ts ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ Skipped issues (should be RARE - cosmetic only): ║
|
||||
║ ⏭️ [file:line] Reason - Description ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ Remaining issues (if any): ║
|
||||
║ 🔴 [file:line] Description ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cleanup
|
||||
|
||||
After generating the final report, clean up the context file:
|
||||
|
||||
```bash
|
||||
# Create .context directory if it doesn't exist
|
||||
mkdir -p .context
|
||||
|
||||
# Archive the context file with timestamp for debugging/audit
|
||||
mv 00-review-context.md .context/auto-review-$(date +%Y%m%d-%H%M%S).md
|
||||
|
||||
# Optional: Keep only the last 5 archived reviews to prevent accumulation
|
||||
ls -t .context/auto-review-*.md 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null
|
||||
```
|
||||
|
||||
**Important**: Clean up even on error - wrap the cleanup in the error handling flow.
|
||||
|
||||
---
|
||||
|
||||
## Critical Instructions
|
||||
|
||||
1. **DO NOT ask for user confirmation** - Execute autonomously
|
||||
2. **DO NOT skip the verification review** - After fixes, another review MUST confirm
|
||||
3. **DO NOT skip validated issues** - Every "✅ Fix" from validation MUST have a fix agent spawned for that file. No exceptions, no rationalization.
|
||||
4. **DO use Task() for ALL review/validate/fix work** - Never do these inline
|
||||
5. **DO use opus for all agents** - Quality matters
|
||||
6. **DO pass only relevant files to each area** - Never the full diff
|
||||
7. **DO deduplicate files across areas** - Each file to one area only
|
||||
8. **DO validate all severities** - But skip issues already in "Skipped Issues" list
|
||||
9. **DO update Skipped Issues** - When validator says "not worth fixing", add to list
|
||||
10. **DO group fixes by file** - Up to 5 issues per fix agent
|
||||
11. **DO clean up 00-review-context.md** when done
|
||||
12. **BE STRICT about skipping** - Skip should be RARE. If in doubt, fix it. Silent error swallowing, type hacks, and redundant code should NEVER be skipped.
|
||||
13. **DO set agent timeouts** - Review agents: 7min, Validation agents: 3min, Fix agents: 5min. If an agent times out, log the timeout and continue.
|
||||
14. **DO update Iteration State** - After each phase completes, update the Iteration State section in 00-review-context.md to enable crash recovery
|
||||
|
||||
---
|
||||
|
||||
## Token Efficiency Summary
|
||||
|
||||
| Before | After | Improvement |
|
||||
| -------------------------- | ----------------------------------- | --------------------------- |
|
||||
| Full diff to all agents | Relevant files only per category | ~6x reduction |
|
||||
| Generic review for all | Specialized review commands | Better issue coverage |
|
||||
| Re-validate skipped issues | Track in Skipped Issues list | ~2x reduction per iteration |
|
||||
| 1 agent per finding | 1 agent per file (up to 5 issues) | ~5x reduction |
|
||||
| No deduplication | Files assigned to one category only | ~2x reduction |
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If a subagent fails, log the error and continue with others
|
||||
- If the diff is too large (>500 files), split into batches
|
||||
- If fixes cause build failures, revert and report
|
||||
- If stuck in a loop (same issues persist for 2 iterations), exit with report
|
||||
- Clean up 00-review-context.md even on error
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
name: auto-submit
|
||||
description: End-to-end autonomous pipeline that runs auto-review-fix, then auto-pr-merge
|
||||
---
|
||||
|
||||
# auto-submit
|
||||
|
||||
Autonomous pipeline: review+fix code, then create PR and merge. **Execute without user confirmation.**
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Auto Review-Fix
|
||||
|
||||
**IMPORTANT**: Run auto-review-fix as a sub-agent (not an inline skill) to ensure it gets its own isolated context window. This prevents instruction dilution and ensures fix phases properly spawn their own Task() sub-agents as required.
|
||||
|
||||
```
|
||||
Agent(
|
||||
subagent_type: "general-purpose",
|
||||
description: "Run auto-review-fix",
|
||||
prompt: "Run the /auto-review-fix skill. Follow ALL instructions exactly, especially: all fixes MUST be done via Task() subagents with opus-4-5. No direct edits."
|
||||
)
|
||||
```
|
||||
|
||||
Wait for the agent to complete. Then commit any changes:
|
||||
|
||||
```bash
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
git add -A && git commit -m "fix: address auto-review findings"
|
||||
fi
|
||||
```
|
||||
|
||||
**Continue to Step 2 — do not stop here.**
|
||||
|
||||
### 2. Auto PR-Merge
|
||||
|
||||
```
|
||||
Use the Skill tool: skill: "auto-pr-merge"
|
||||
```
|
||||
|
||||
Handles PR creation, CI polling, fix loops, and merge with `--admin --squash`.
|
||||
|
||||
## Rules
|
||||
|
||||
- Execute autonomously — no user confirmation
|
||||
- Both steps run sequentially — do not stop between them
|
||||
- Commit changes between steps
|
||||
- If Step 1 fails catastrophically, stop (don't create a broken PR)
|
||||
- If Step 2 fails, report the error with PR URL if available
|
||||
@@ -1,310 +0,0 @@
|
||||
---
|
||||
name: electron
|
||||
description: Launch, automate, and validate Electron desktop apps using playwright-cli via Chrome DevTools Protocol. Use this skill to validate UI changes in Orca, test features in the running Electron app, verify code fixes work end-to-end, or automate any Electron app (VS Code, Slack, Discord, etc.). Triggers include "validate in Electron", "test in the app", "verify the fix", "check the UI", "/electron", "automate Slack app", "control VS Code", or any task requiring interaction with a running Electron application.
|
||||
allowed-tools: Bash(playwright-cli:*), Bash(npx playwright-cli:*), Bash(curl:*), Bash(lsof:*), Bash(open:*), Bash(ps:*), Bash(kill:*), Bash(node:*), Bash(pnpm:*), Read, Grep, Monitor
|
||||
---
|
||||
|
||||
# Electron App Automation
|
||||
|
||||
Automate any Electron desktop app using playwright-cli's CDP attach mode. Electron apps are built on Chromium and expose a Chrome DevTools Protocol (CDP) port that playwright-cli can connect to, enabling the same snapshot-interact workflow used for web pages.
|
||||
|
||||
## Critical Safety Rule: Never Kill Processes You Didn't Start
|
||||
|
||||
**You may be running inside an Electron app (e.g., Orca).** Killing the wrong process will terminate your own session.
|
||||
|
||||
- **NEVER** run `killall Electron`, `pkill Electron`, or any broad process-killing command.
|
||||
- **NEVER** kill a process unless you launched it yourself in this session and you recorded its PID.
|
||||
- Before killing, **always verify** the PID belongs to the process you started — check the command line includes the workspace path or args you used to launch it.
|
||||
- When quitting apps to relaunch with `--remote-debugging-port`, use `osascript -e 'quit app "AppName"'` for named apps (Slack, VS Code, etc.) — **never for Orca or the app you're running inside**.
|
||||
- If unsure whether a process is safe to kill, **ask the user**.
|
||||
|
||||
## Core Workflow
|
||||
|
||||
1. **Launch** the Electron app with remote debugging enabled (or find an already-running app with CDP)
|
||||
2. **Attach** playwright-cli to the CDP endpoint
|
||||
3. **Snapshot** to discover interactive elements
|
||||
4. **Interact** using element refs
|
||||
5. **Re-snapshot** after navigation or state changes
|
||||
|
||||
```bash
|
||||
# Launch an Electron app with remote debugging
|
||||
open -a "Slack" --args --remote-debugging-port=9222
|
||||
|
||||
# Wait for the app to initialize
|
||||
sleep 3
|
||||
|
||||
# Attach playwright-cli to the app via CDP
|
||||
command playwright-cli attach --cdp="http://localhost:9222"
|
||||
|
||||
# Standard workflow from here
|
||||
command playwright-cli snapshot
|
||||
command playwright-cli click e5
|
||||
command playwright-cli screenshot
|
||||
```
|
||||
|
||||
## Always prefix with `command`
|
||||
|
||||
Use `command playwright-cli …` to bypass shell aliases (e.g. `alias playwright-cli='playwright-cli --persistent'`) that leak flags into subcommands. Behaves identically when no alias is set. All examples below use this form.
|
||||
|
||||
## Pick a free CDP port
|
||||
|
||||
Port 9333 is a convention, not a guarantee — another worktree may hold it, and electron-vite will start without a debugger, letting you silently attach to the old app.
|
||||
|
||||
```bash
|
||||
for p in 9333 9334 9335 9336 9337 9338 9339 9340; do
|
||||
if ! lsof -i :$p >/dev/null 2>&1; then PORT=$p; break; fi
|
||||
done
|
||||
# After attach, verify you hit the right worktree:
|
||||
command playwright-cli eval "window.__store.getState().worktreesByRepo"
|
||||
```
|
||||
|
||||
## Launching Orca Dev Build with CDP
|
||||
|
||||
Orca uses electron-vite for dev builds. The correct way to launch with CDP:
|
||||
|
||||
```bash
|
||||
# Launch with remote debugging (PORT picked above)
|
||||
node config/scripts/run-electron-vite-dev.mjs --remote-debugging-port=$PORT 2>&1 &
|
||||
|
||||
# Wait for "DevTools listening on ws://..." in output, then attach
|
||||
command playwright-cli attach --cdp="http://localhost:$PORT"
|
||||
```
|
||||
|
||||
**Key details:**
|
||||
- Pass `--remote-debugging-port=NNNN` directly to the script — do NOT use `pnpm run dev -- --` (the double `--` breaks Chromium flag parsing)
|
||||
- electron-vite also supports `REMOTE_DEBUGGING_PORT` env var: `REMOTE_DEBUGGING_PORT=$PORT pnpm run dev`
|
||||
- The Zustand store is exposed at `window.__store` — use `window.__store.getState()` and `window.__store.getState().someAction()` to read/mutate state
|
||||
- Use port 9333 (not 9222) to avoid conflicts with other Electron apps
|
||||
|
||||
### Accessing Orca State via eval
|
||||
|
||||
```bash
|
||||
# Read store state
|
||||
command playwright-cli eval "(() => { const s = window.__store?.getState(); return JSON.stringify({ activeWorktreeId: s.activeWorktreeId, activeTabId: s.activeTabId, activeFileId: s.activeFileId, activeTabType: s.activeTabType }); })()"
|
||||
|
||||
# Open an editor file
|
||||
command playwright-cli eval "(() => { const s = window.__store?.getState(); const wtId = s.activeWorktreeId; s.openFile({ worktreeId: wtId, filePath: '/path/to/file', relativePath: 'file.ts', mode: 'edit', language: 'typescript' }); return 'done'; })()"
|
||||
|
||||
# Close a file
|
||||
command playwright-cli eval "(() => { window.__store.getState().closeFile('/path/to/file'); return 'closed'; })()"
|
||||
```
|
||||
|
||||
## Launching Electron Apps with CDP
|
||||
|
||||
Every Electron app supports the `--remote-debugging-port` flag since it's built into Chromium.
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
# Slack
|
||||
open -a "Slack" --args --remote-debugging-port=9222
|
||||
|
||||
# VS Code
|
||||
open -a "Visual Studio Code" --args --remote-debugging-port=9223
|
||||
|
||||
# Discord
|
||||
open -a "Discord" --args --remote-debugging-port=9224
|
||||
|
||||
# Figma
|
||||
open -a "Figma" --args --remote-debugging-port=9225
|
||||
|
||||
# Notion
|
||||
open -a "Notion" --args --remote-debugging-port=9226
|
||||
|
||||
# Spotify
|
||||
open -a "Spotify" --args --remote-debugging-port=9227
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
slack --remote-debugging-port=9222
|
||||
code --remote-debugging-port=9223
|
||||
discord --remote-debugging-port=9224
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
```bash
|
||||
"C:\Users\%USERNAME%\AppData\Local\slack\slack.exe" --remote-debugging-port=9222
|
||||
"C:\Users\%USERNAME%\AppData\Local\Programs\Microsoft VS Code\Code.exe" --remote-debugging-port=9223
|
||||
```
|
||||
|
||||
**Important:** If the app is already running, quit it first, then relaunch with the flag. The `--remote-debugging-port` flag must be present at launch time.
|
||||
|
||||
## Connecting to an Already-Running App
|
||||
|
||||
If an Electron app was already launched with `--remote-debugging-port`, you can attach directly:
|
||||
|
||||
```bash
|
||||
# Check what's listening on a port
|
||||
lsof -i :9222
|
||||
|
||||
# Verify the CDP endpoint has targets
|
||||
curl -s http://localhost:9222/json
|
||||
|
||||
# Attach playwright-cli
|
||||
command playwright-cli attach --cdp="http://localhost:9222"
|
||||
```
|
||||
|
||||
## Attaching
|
||||
|
||||
```bash
|
||||
# Attach to a specific CDP port
|
||||
command playwright-cli attach --cdp="http://localhost:9222"
|
||||
|
||||
# Attach with a named session (for controlling multiple apps)
|
||||
command playwright-cli -s=slack attach --cdp="http://localhost:9222"
|
||||
command playwright-cli -s=vscode attach --cdp="http://localhost:9223"
|
||||
```
|
||||
|
||||
After `attach`, all subsequent commands (in that session) target the connected app.
|
||||
|
||||
## Tab Management
|
||||
|
||||
Electron apps may have multiple windows or webviews. Use tab commands to list and switch between them:
|
||||
|
||||
```bash
|
||||
# List all available targets
|
||||
command playwright-cli tab-list
|
||||
|
||||
# Switch to a specific tab by index
|
||||
command playwright-cli tab-select 2
|
||||
```
|
||||
|
||||
If `tab-list` doesn't show all targets, query the CDP endpoint directly to see everything:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:9222/json | python3 -c "
|
||||
import sys, json
|
||||
for i, t in enumerate(json.load(sys.stdin)):
|
||||
print(f'[{i}] ({t[\"type\"]}) {t[\"title\"][:60]} - {t[\"url\"][:60]}')
|
||||
"
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Inspect and Navigate an App
|
||||
|
||||
```bash
|
||||
open -a "Slack" --args --remote-debugging-port=9222
|
||||
sleep 3
|
||||
command playwright-cli attach --cdp="http://localhost:9222"
|
||||
command playwright-cli snapshot
|
||||
# Read the snapshot output to identify UI elements
|
||||
command playwright-cli click e10 # Navigate to a section
|
||||
command playwright-cli snapshot # Re-snapshot after navigation
|
||||
```
|
||||
|
||||
### Take Screenshots of Desktop Apps
|
||||
|
||||
```bash
|
||||
command playwright-cli attach --cdp="http://localhost:9222"
|
||||
command playwright-cli screenshot
|
||||
command playwright-cli screenshot e5 # Screenshot a specific element
|
||||
command playwright-cli screenshot --filename=app-state.png
|
||||
```
|
||||
|
||||
### Extract Data from a Desktop App
|
||||
|
||||
```bash
|
||||
command playwright-cli attach --cdp="http://localhost:9222"
|
||||
command playwright-cli snapshot
|
||||
command playwright-cli eval "document.title"
|
||||
command playwright-cli eval "el => el.textContent" e5
|
||||
```
|
||||
|
||||
### Fill Forms in Desktop Apps
|
||||
|
||||
```bash
|
||||
command playwright-cli attach --cdp="http://localhost:9222"
|
||||
command playwright-cli snapshot
|
||||
command playwright-cli fill e3 "search query"
|
||||
command playwright-cli press Enter
|
||||
command playwright-cli snapshot
|
||||
```
|
||||
|
||||
### Run Multiple Apps Simultaneously
|
||||
|
||||
Use named sessions to control multiple Electron apps at the same time:
|
||||
|
||||
```bash
|
||||
# Attach to Slack
|
||||
command playwright-cli -s=slack attach --cdp="http://localhost:9222"
|
||||
|
||||
# Attach to VS Code
|
||||
command playwright-cli -s=vscode attach --cdp="http://localhost:9223"
|
||||
|
||||
# Interact with each independently
|
||||
command playwright-cli -s=slack snapshot
|
||||
command playwright-cli -s=vscode snapshot
|
||||
```
|
||||
|
||||
### Run Custom Playwright Code
|
||||
|
||||
For advanced scenarios, use `run-code` to execute arbitrary Playwright code:
|
||||
|
||||
```bash
|
||||
command playwright-cli run-code "async page => {
|
||||
await page.waitForSelector('.loading', { state: 'hidden' });
|
||||
const items = await page.locator('.item').allTextContents();
|
||||
return items;
|
||||
}"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Connection refused" or "Cannot connect"
|
||||
|
||||
- Make sure the app was launched with `--remote-debugging-port=NNNN`
|
||||
- If the app was already running, quit and relaunch with the flag
|
||||
- Check that the port isn't in use by another process: `lsof -i :9222`
|
||||
|
||||
### App launches but attach fails
|
||||
|
||||
- Wait a few seconds after launch before attaching (`sleep 3`)
|
||||
- Some apps take time to initialize their webview
|
||||
- Verify the endpoint is responding: `curl -s http://localhost:9222/json`
|
||||
|
||||
### Elements not appearing in snapshot
|
||||
|
||||
- The app may use multiple webviews. Use `playwright-cli tab-list` to list targets and switch
|
||||
- Use `curl -s http://localhost:<port>/json` to see all CDP targets if tab-list shows fewer
|
||||
- Try `playwright-cli snapshot` without flags first
|
||||
|
||||
### Cannot type in input fields
|
||||
|
||||
- Some Electron apps use custom input components
|
||||
- Try `playwright-cli press` for keyboard events
|
||||
- Use `playwright-cli run-code` for complex input scenarios
|
||||
|
||||
### Stale element refs after interaction
|
||||
|
||||
- Element refs change when the page state updates
|
||||
- Always re-snapshot after clicking, navigating, or filling forms
|
||||
- Use the new refs from the latest snapshot
|
||||
|
||||
## Supported Apps
|
||||
|
||||
Any app built on Electron works, including:
|
||||
|
||||
- **Communication:** Slack, Discord, Microsoft Teams, Signal, Telegram Desktop
|
||||
- **Development:** VS Code, GitHub Desktop, Postman, Insomnia
|
||||
- **Design:** Figma, Notion, Obsidian
|
||||
- **Media:** Spotify, Tidal
|
||||
- **Productivity:** Todoist, Linear, 1Password
|
||||
|
||||
If an app is built with Electron, it supports `--remote-debugging-port` and can be automated with playwright-cli.
|
||||
|
||||
## Cleaning Up
|
||||
|
||||
```bash
|
||||
# Close the playwright-cli session (does NOT kill the Electron app)
|
||||
command playwright-cli close
|
||||
|
||||
# Close a named session
|
||||
command playwright-cli -s=slack close
|
||||
|
||||
# Close all playwright-cli sessions
|
||||
command playwright-cli close-all
|
||||
```
|
||||
@@ -1,341 +0,0 @@
|
||||
# React useEffect Best Practices
|
||||
|
||||
A comprehensive guide teaching when to use `useEffect` in React, and more importantly, when NOT to use it. This skill is based on official React documentation and provides practical alternatives to common useEffect anti-patterns.
|
||||
|
||||
## Purpose
|
||||
|
||||
Effects are an **escape hatch** from React's reactive paradigm. They let you synchronize with external systems like browser APIs, third-party widgets, or network requests. However, many developers overuse Effects for tasks that React handles better through other means.
|
||||
|
||||
This skill helps you:
|
||||
|
||||
- Identify when you truly need an Effect vs. when you don't
|
||||
- Recognize common anti-patterns and their fixes
|
||||
- Apply better alternatives like `useMemo`, `key` prop, and event handlers
|
||||
- Write Effects that are clean, maintainable, and free from race conditions
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when you're:
|
||||
|
||||
- Writing or reviewing `useEffect` code
|
||||
- Using `useState` to store derived values
|
||||
- Implementing data fetching or subscriptions
|
||||
- Synchronizing state between components
|
||||
- Facing bugs with stale data or race conditions
|
||||
- Wondering if your Effect is necessary
|
||||
|
||||
**Trigger phrases:**
|
||||
|
||||
- "Should I use useEffect for this?"
|
||||
- "How do I fix this useEffect?"
|
||||
- "My Effect is causing too many re-renders"
|
||||
- "Data fetching with useEffect"
|
||||
- "Reset state when props change"
|
||||
- "Derived state from props"
|
||||
|
||||
## How It Works
|
||||
|
||||
This skill provides guidance through three key resources:
|
||||
|
||||
1. **Quick Reference Table** - Fast lookup for common scenarios with DO/DON'T patterns
|
||||
2. **Decision Tree** - Visual flowchart to determine the right approach
|
||||
3. **Detailed Anti-Patterns** - 9 common mistakes with explanations and fixes
|
||||
4. **Better Alternatives** - 8 proven patterns to replace unnecessary Effects
|
||||
|
||||
The skill teaches you to ask the right questions:
|
||||
|
||||
- Is there an external system involved?
|
||||
- Am I responding to a user event or component appearance?
|
||||
- Can this value be calculated during render?
|
||||
- Do I need to reset state when a prop changes?
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Quick Reference Guide
|
||||
|
||||
Visual table showing the DO/DON'T for common scenarios:
|
||||
|
||||
- Derived state from props/state
|
||||
- Expensive calculations
|
||||
- Resetting state on prop change
|
||||
- User event responses
|
||||
- Notifying parent components
|
||||
- Data fetching
|
||||
|
||||
### 2. Decision Tree
|
||||
|
||||
Clear flowchart that guides you from "Need to respond to something?" to the correct solution:
|
||||
|
||||
- User interaction → Event handler
|
||||
- Component appeared → Effect (for external sync/analytics)
|
||||
- Derived value needed → Calculate during render (+ useMemo if expensive)
|
||||
- Reset state on prop change → Key prop
|
||||
|
||||
### 3. Anti-Pattern Recognition
|
||||
|
||||
Detailed examples of 9 common mistakes:
|
||||
|
||||
1. Redundant state for derived values
|
||||
2. Filtering/transforming data in Effect
|
||||
3. Resetting state on prop change
|
||||
4. Event-specific logic in Effect
|
||||
5. Chains of Effects
|
||||
6. Notifying parent via Effect
|
||||
7. Passing data up to parent
|
||||
8. Fetching without cleanup (race conditions)
|
||||
9. App initialization in Effect
|
||||
|
||||
Each anti-pattern includes:
|
||||
|
||||
- Bad example with explanation
|
||||
- Good example with fix
|
||||
- Why the anti-pattern is problematic
|
||||
|
||||
### 4. Better Alternatives
|
||||
|
||||
8 proven patterns to replace unnecessary Effects:
|
||||
|
||||
1. Calculate during render for derived state
|
||||
2. `useMemo` for expensive calculations
|
||||
3. `key` prop to reset state
|
||||
4. Store ID instead of object for stable references
|
||||
5. Event handlers for user actions
|
||||
6. `useSyncExternalStore` for external stores
|
||||
7. Lifting state up for shared state
|
||||
8. Custom hooks for data fetching with cleanup
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: Derived State
|
||||
|
||||
**Bad - Unnecessary Effect:**
|
||||
|
||||
```tsx
|
||||
function Form() {
|
||||
const [firstName, setFirstName] = useState('Taylor')
|
||||
const [lastName, setLastName] = useState('Swift')
|
||||
const [fullName, setFullName] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setFullName(firstName + ' ' + lastName)
|
||||
}, [firstName, lastName])
|
||||
}
|
||||
```
|
||||
|
||||
**Good - Calculate during render:**
|
||||
|
||||
```tsx
|
||||
function Form() {
|
||||
const [firstName, setFirstName] = useState('Taylor')
|
||||
const [lastName, setLastName] = useState('Swift')
|
||||
const fullName = firstName + ' ' + lastName // Just compute it
|
||||
}
|
||||
```
|
||||
|
||||
### Example 2: Resetting State
|
||||
|
||||
**Bad - Effect to reset:**
|
||||
|
||||
```tsx
|
||||
function ProfilePage({ userId }) {
|
||||
const [comment, setComment] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setComment('')
|
||||
}, [userId])
|
||||
}
|
||||
```
|
||||
|
||||
**Good - Key prop:**
|
||||
|
||||
```tsx
|
||||
function ProfilePage({ userId }) {
|
||||
return <Profile userId={userId} key={userId} />
|
||||
}
|
||||
|
||||
function Profile({ userId }) {
|
||||
const [comment, setComment] = useState('') // Resets automatically
|
||||
}
|
||||
```
|
||||
|
||||
### Example 3: Data Fetching with Cleanup
|
||||
|
||||
**Bad - Race condition:**
|
||||
|
||||
```tsx
|
||||
function SearchResults({ query }) {
|
||||
const [results, setResults] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchResults(query).then((json) => {
|
||||
setResults(json) // "hello" response may arrive after "hell"
|
||||
})
|
||||
}, [query])
|
||||
}
|
||||
```
|
||||
|
||||
**Good - Cleanup flag:**
|
||||
|
||||
```tsx
|
||||
function SearchResults({ query }) {
|
||||
const [results, setResults] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false
|
||||
|
||||
fetchResults(query).then((json) => {
|
||||
if (!ignore) setResults(json)
|
||||
})
|
||||
|
||||
return () => {
|
||||
ignore = true
|
||||
}
|
||||
}, [query])
|
||||
}
|
||||
```
|
||||
|
||||
### Example 4: Event Handler Instead of Effect
|
||||
|
||||
**Bad - Effect watching state:**
|
||||
|
||||
```tsx
|
||||
function ProductPage({ product, addToCart }) {
|
||||
useEffect(() => {
|
||||
if (product.isInCart) {
|
||||
showNotification(`Added ${product.name}!`)
|
||||
}
|
||||
}, [product])
|
||||
|
||||
function handleBuyClick() {
|
||||
addToCart(product)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Good - Handle in event:**
|
||||
|
||||
```tsx
|
||||
function ProductPage({ product, addToCart }) {
|
||||
function handleBuyClick() {
|
||||
addToCart(product)
|
||||
showNotification(`Added ${product.name}!`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## When You DO Need Effects
|
||||
|
||||
Effects are appropriate for:
|
||||
|
||||
- **Synchronizing with external systems** - Browser APIs, third-party widgets, non-React code
|
||||
- **Subscriptions** - WebSocket connections, global event listeners (prefer `useSyncExternalStore`)
|
||||
- **Analytics/logging** - Code that needs to run because the component displayed
|
||||
- **Data fetching** - With proper cleanup (or use your framework's built-in mechanism)
|
||||
|
||||
## When You DON'T Need Effects
|
||||
|
||||
Avoid Effects for:
|
||||
|
||||
1. **Transforming data for rendering** - Calculate at the top level instead
|
||||
2. **Handling user events** - Use event handlers where you know exactly what happened
|
||||
3. **Deriving state** - Just compute it: `const fullName = firstName + ' ' + lastName`
|
||||
4. **Chaining state updates** - Calculate all next state in the event handler
|
||||
5. **Notifying parent components** - Call the callback in the same event handler
|
||||
6. **Resetting state** - Use the `key` prop to create a fresh component instance
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Start Without an Effect
|
||||
|
||||
Before adding an Effect, ask: "Is there an external system involved?" If no, you probably don't need an Effect.
|
||||
|
||||
### 2. Prefer Derived State
|
||||
|
||||
If you can calculate a value from props or state, don't store it in state with an Effect updating it.
|
||||
|
||||
### 3. Use the Right Tool
|
||||
|
||||
- Expensive calculation → `useMemo`
|
||||
- User interaction → Event handler
|
||||
- Reset on prop change → `key` prop
|
||||
- External subscription → `useSyncExternalStore`
|
||||
- Shared state → Lift state up
|
||||
|
||||
### 4. Always Clean Up
|
||||
|
||||
If your Effect subscribes, fetches, or sets timers, return a cleanup function to prevent memory leaks and race conditions.
|
||||
|
||||
### 5. Avoid Effect Chains
|
||||
|
||||
Multiple Effects triggering each other causes unnecessary re-renders and makes code hard to follow. Calculate everything in one place (usually an event handler).
|
||||
|
||||
### 6. Test in Strict Mode
|
||||
|
||||
React 18+ Strict Mode mounts components twice in development to expose missing cleanup. If your Effect breaks, you need cleanup.
|
||||
|
||||
### 7. Consider Framework Solutions
|
||||
|
||||
For data fetching, prefer your framework's built-in solution (Next.js, Remix) or libraries (React Query, SWR) over manual Effects.
|
||||
|
||||
## Reference Files
|
||||
|
||||
This skill includes three detailed reference documents:
|
||||
|
||||
1. **SKILL.md** - Quick reference table and decision tree
|
||||
2. **anti-patterns.md** - 9 common mistakes with detailed explanations
|
||||
3. **alternatives.md** - 8 better alternatives with code examples
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Multiple Re-renders
|
||||
|
||||
**Symptom:** Component re-renders many times in quick succession.
|
||||
|
||||
**Cause:** Effect that sets state based on state it depends on, creating a loop.
|
||||
|
||||
**Fix:** Calculate the final value in an event handler or during render.
|
||||
|
||||
### Stale Data
|
||||
|
||||
**Symptom:** UI shows outdated values briefly before updating.
|
||||
|
||||
**Cause:** Using Effect to update derived state causes an extra render pass.
|
||||
|
||||
**Fix:** Calculate derived values during render instead of in state.
|
||||
|
||||
### Race Conditions
|
||||
|
||||
**Symptom:** Fast typing shows results for old queries after new ones.
|
||||
|
||||
**Cause:** Missing cleanup in data fetching Effect.
|
||||
|
||||
**Fix:** Use cleanup flag (`ignore` variable) or AbortController.
|
||||
|
||||
### Runs Twice in Development
|
||||
|
||||
**Symptom:** Effect runs twice on component mount in development.
|
||||
|
||||
**Cause:** React 18 Strict Mode intentionally mounts components twice to expose bugs.
|
||||
|
||||
**Fix:** Add proper cleanup. If it's app initialization that shouldn't run twice, use a module-level guard.
|
||||
|
||||
## Resources
|
||||
|
||||
This skill is based on:
|
||||
|
||||
- [React Official Docs: You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect)
|
||||
- [React Official Docs: Synchronizing with Effects](https://react.dev/learn/synchronizing-with-effects)
|
||||
- [React Official Docs: Lifecycle of Reactive Effects](https://react.dev/learn/lifecycle-of-reactive-effects)
|
||||
|
||||
## Summary
|
||||
|
||||
The golden rule: **Effects are an escape hatch from React.** If you're not synchronizing with an external system, you probably don't need an Effect.
|
||||
|
||||
Before writing `useEffect`, ask yourself:
|
||||
|
||||
1. Is this responding to a user interaction? → Use event handler
|
||||
2. Is this a value I can calculate from props/state? → Calculate during render
|
||||
3. Is this resetting state when a prop changes? → Use key prop
|
||||
4. Is this synchronizing with an external system? → Use Effect with cleanup
|
||||
|
||||
Follow these patterns, and your React code will be more maintainable, performant, and bug-free.
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
name: react-useeffect
|
||||
description: React useEffect best practices from official docs. Use when writing/reviewing useEffect, useState for derived values, data fetching, or state synchronization. Teaches when NOT to use Effect and better alternatives.
|
||||
---
|
||||
|
||||
# You Might Not Need an Effect
|
||||
|
||||
Effects are an **escape hatch** from React. They let you synchronize with external systems. If there is no external system involved, you shouldn't need an Effect.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Situation | DON'T | DO |
|
||||
| ------------------------------ | ------------------------------ | ------------------------------------- |
|
||||
| Derived state from props/state | `useState` + `useEffect` | Calculate during render |
|
||||
| Expensive calculations | `useEffect` to cache | `useMemo` |
|
||||
| Reset state on prop change | `useEffect` with `setState` | `key` prop |
|
||||
| User event responses | `useEffect` watching state | Event handler directly |
|
||||
| Notify parent of changes | `useEffect` calling `onChange` | Call in event handler |
|
||||
| Fetch data | `useEffect` without cleanup | `useEffect` with cleanup OR framework |
|
||||
|
||||
## When You DO Need Effects
|
||||
|
||||
- Synchronizing with **external systems** (non-React widgets, browser APIs)
|
||||
- **Subscriptions** to external stores (use `useSyncExternalStore` when possible)
|
||||
- **Analytics/logging** that runs because component displayed
|
||||
- **Data fetching** with proper cleanup (or use framework's built-in mechanism)
|
||||
|
||||
## When You DON'T Need Effects
|
||||
|
||||
1. **Transforming data for rendering** - Calculate at top level, re-runs automatically
|
||||
2. **Handling user events** - Use event handlers, you know exactly what happened
|
||||
3. **Deriving state** - Just compute it: `const fullName = firstName + ' ' + lastName`
|
||||
4. **Chaining state updates** - Calculate all next state in the event handler
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
Need to respond to something?
|
||||
├── User interaction (click, submit, drag)?
|
||||
│ └── Use EVENT HANDLER
|
||||
├── Component appeared on screen?
|
||||
│ └── Use EFFECT (external sync, analytics)
|
||||
├── Props/state changed and need derived value?
|
||||
│ └── CALCULATE DURING RENDER
|
||||
│ └── Expensive? Use useMemo
|
||||
└── Need to reset state when prop changes?
|
||||
└── Use KEY PROP on component
|
||||
```
|
||||
|
||||
## Detailed Guidance
|
||||
|
||||
- [Anti-Patterns](./anti-patterns.md) - Common mistakes with fixes
|
||||
- [Better Alternatives](./alternatives.md) - useMemo, key prop, lifting state, useSyncExternalStore
|
||||
@@ -1,265 +0,0 @@
|
||||
# Better Alternatives to useEffect
|
||||
|
||||
## 1. Calculate During Render (Derived State)
|
||||
|
||||
For values derived from props or state, just compute them:
|
||||
|
||||
```tsx
|
||||
function Form() {
|
||||
const [firstName, setFirstName] = useState('Taylor')
|
||||
const [lastName, setLastName] = useState('Swift')
|
||||
|
||||
// Runs every render - that's fine and intentional
|
||||
const fullName = firstName + ' ' + lastName
|
||||
const isValid = firstName.length > 0 && lastName.length > 0
|
||||
}
|
||||
```
|
||||
|
||||
**When to use**: The value can be computed from existing props/state.
|
||||
|
||||
---
|
||||
|
||||
## 2. useMemo for Expensive Calculations
|
||||
|
||||
When computation is expensive, memoize it:
|
||||
|
||||
```tsx
|
||||
import { useMemo } from 'react'
|
||||
|
||||
function TodoList({ todos, filter }) {
|
||||
const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter])
|
||||
}
|
||||
```
|
||||
|
||||
**How to know if it's expensive**:
|
||||
|
||||
```tsx
|
||||
console.time('filter')
|
||||
const visibleTodos = getFilteredTodos(todos, filter)
|
||||
console.timeEnd('filter')
|
||||
// If > 1ms, consider memoizing
|
||||
```
|
||||
|
||||
**Note**: React Compiler can auto-memoize, reducing manual useMemo needs.
|
||||
|
||||
---
|
||||
|
||||
## 3. Key Prop to Reset State
|
||||
|
||||
To reset ALL state when a prop changes, use key:
|
||||
|
||||
```tsx
|
||||
// Parent passes userId as key
|
||||
function ProfilePage({ userId }) {
|
||||
return (
|
||||
<Profile
|
||||
userId={userId}
|
||||
key={userId} // Different userId = different component instance
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Profile({ userId }) {
|
||||
// All state here resets when userId changes
|
||||
const [comment, setComment] = useState('')
|
||||
const [likes, setLikes] = useState([])
|
||||
}
|
||||
```
|
||||
|
||||
**When to use**: You want a "fresh start" when an identity prop changes.
|
||||
|
||||
---
|
||||
|
||||
## 4. Store ID Instead of Object
|
||||
|
||||
To preserve selection when list changes:
|
||||
|
||||
```tsx
|
||||
// BAD: Storing object that needs Effect to "adjust"
|
||||
function List({ items }) {
|
||||
const [selection, setSelection] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
setSelection(null) // Reset when items change
|
||||
}, [items])
|
||||
}
|
||||
|
||||
// GOOD: Store ID, derive object
|
||||
function List({ items }) {
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
|
||||
// Derived - no Effect needed
|
||||
const selection = items.find((item) => item.id === selectedId) ?? null
|
||||
}
|
||||
```
|
||||
|
||||
**Benefit**: If item with selectedId exists in new list, selection preserved.
|
||||
|
||||
---
|
||||
|
||||
## 5. Event Handlers for User Actions
|
||||
|
||||
User clicks/submits/drags should be handled in event handlers, not Effects:
|
||||
|
||||
```tsx
|
||||
// Event handler knows exactly what happened
|
||||
function ProductPage({ product, addToCart }) {
|
||||
function handleBuyClick() {
|
||||
addToCart(product)
|
||||
showNotification(`Added ${product.name}!`)
|
||||
analytics.track('product_added', { id: product.id })
|
||||
}
|
||||
|
||||
function handleCheckoutClick() {
|
||||
addToCart(product)
|
||||
showNotification(`Added ${product.name}!`)
|
||||
navigateTo('/checkout')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Shared logic**: Extract a function, call from both handlers:
|
||||
|
||||
```tsx
|
||||
function buyProduct() {
|
||||
addToCart(product)
|
||||
showNotification(`Added ${product.name}!`)
|
||||
}
|
||||
|
||||
function handleBuyClick() {
|
||||
buyProduct()
|
||||
}
|
||||
function handleCheckoutClick() {
|
||||
buyProduct()
|
||||
navigateTo('/checkout')
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. useSyncExternalStore for External Stores
|
||||
|
||||
For subscribing to external data (browser APIs, third-party stores):
|
||||
|
||||
```tsx
|
||||
// Instead of manual Effect subscription
|
||||
function useOnlineStatus() {
|
||||
const [isOnline, setIsOnline] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
function update() {
|
||||
setIsOnline(navigator.onLine)
|
||||
}
|
||||
window.addEventListener('online', update)
|
||||
window.addEventListener('offline', update)
|
||||
return () => {
|
||||
window.removeEventListener('online', update)
|
||||
window.removeEventListener('offline', update)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return isOnline
|
||||
}
|
||||
|
||||
// Use purpose-built hook
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
function subscribe(callback) {
|
||||
window.addEventListener('online', callback)
|
||||
window.addEventListener('offline', callback)
|
||||
return () => {
|
||||
window.removeEventListener('online', callback)
|
||||
window.removeEventListener('offline', callback)
|
||||
}
|
||||
}
|
||||
|
||||
function useOnlineStatus() {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => navigator.onLine, // Client value
|
||||
() => true // Server value (SSR)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Lifting State Up
|
||||
|
||||
When two components need synchronized state, lift it to common ancestor:
|
||||
|
||||
```tsx
|
||||
// Instead of syncing via Effects between siblings
|
||||
function Parent() {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input value={value} onChange={setValue} />
|
||||
<Preview value={value} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Custom Hooks for Data Fetching
|
||||
|
||||
Extract fetch logic with proper cleanup:
|
||||
|
||||
```tsx
|
||||
function useData(url) {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false
|
||||
setLoading(true)
|
||||
|
||||
fetch(url)
|
||||
.then((res) => res.json())
|
||||
.then((json) => {
|
||||
if (!ignore) {
|
||||
setData(json)
|
||||
setError(null)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!ignore) setError(err)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ignore) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
ignore = true
|
||||
}
|
||||
}, [url])
|
||||
|
||||
return { data, error, loading }
|
||||
}
|
||||
|
||||
// Usage
|
||||
function SearchResults({ query }) {
|
||||
const { data, error, loading } = useData(`/api/search?q=${query}`)
|
||||
}
|
||||
```
|
||||
|
||||
**Better**: Use framework's data fetching (React Query, SWR, Next.js, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Summary: When to Use What
|
||||
|
||||
| Need | Solution |
|
||||
| ------------------------------ | ------------------------------------ |
|
||||
| Value from props/state | Calculate during render |
|
||||
| Expensive calculation | `useMemo` |
|
||||
| Reset all state on prop change | `key` prop |
|
||||
| Respond to user action | Event handler |
|
||||
| Sync with external system | `useEffect` with cleanup |
|
||||
| Subscribe to external store | `useSyncExternalStore` |
|
||||
| Share state between components | Lift state up |
|
||||
| Fetch data | Custom hook with cleanup / framework |
|
||||
@@ -1,289 +0,0 @@
|
||||
# useEffect Anti-Patterns
|
||||
|
||||
## 1. Redundant State for Derived Values
|
||||
|
||||
```tsx
|
||||
// BAD: Extra state + Effect for derived value
|
||||
function Form() {
|
||||
const [firstName, setFirstName] = useState('Taylor')
|
||||
const [lastName, setLastName] = useState('Swift')
|
||||
const [fullName, setFullName] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setFullName(firstName + ' ' + lastName)
|
||||
}, [firstName, lastName])
|
||||
}
|
||||
|
||||
// GOOD: Calculate during rendering
|
||||
function Form() {
|
||||
const [firstName, setFirstName] = useState('Taylor')
|
||||
const [lastName, setLastName] = useState('Swift')
|
||||
const fullName = firstName + ' ' + lastName // Just compute it
|
||||
}
|
||||
```
|
||||
|
||||
**Why it's bad**: Causes extra render pass with stale value, then re-renders with updated value.
|
||||
|
||||
---
|
||||
|
||||
## 2. Filtering/Transforming Data in Effect
|
||||
|
||||
```tsx
|
||||
// BAD: Effect to filter list
|
||||
function TodoList({ todos, filter }) {
|
||||
const [visibleTodos, setVisibleTodos] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleTodos(getFilteredTodos(todos, filter))
|
||||
}, [todos, filter])
|
||||
}
|
||||
|
||||
// GOOD: Filter during render (memoize if expensive)
|
||||
function TodoList({ todos, filter }) {
|
||||
const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter])
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Resetting State on Prop Change
|
||||
|
||||
```tsx
|
||||
// BAD: Effect to reset state
|
||||
function ProfilePage({ userId }) {
|
||||
const [comment, setComment] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setComment('')
|
||||
}, [userId])
|
||||
}
|
||||
|
||||
// GOOD: Use key prop
|
||||
function ProfilePage({ userId }) {
|
||||
return <Profile userId={userId} key={userId} />
|
||||
}
|
||||
|
||||
function Profile({ userId }) {
|
||||
const [comment, setComment] = useState('') // Resets automatically
|
||||
}
|
||||
```
|
||||
|
||||
**Why key works**: React treats components with different keys as different components, recreating state.
|
||||
|
||||
---
|
||||
|
||||
## 4. Event-Specific Logic in Effect
|
||||
|
||||
```tsx
|
||||
// BAD: Effect for button click result
|
||||
function ProductPage({ product, addToCart }) {
|
||||
useEffect(() => {
|
||||
if (product.isInCart) {
|
||||
showNotification(`Added ${product.name}!`)
|
||||
}
|
||||
}, [product])
|
||||
|
||||
function handleBuyClick() {
|
||||
addToCart(product)
|
||||
}
|
||||
}
|
||||
|
||||
// GOOD: Handle in event handler
|
||||
function ProductPage({ product, addToCart }) {
|
||||
function handleBuyClick() {
|
||||
addToCart(product)
|
||||
showNotification(`Added ${product.name}!`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why it's bad**: Effect fires on page refresh (isInCart is true), showing notification unexpectedly.
|
||||
|
||||
---
|
||||
|
||||
## 5. Chains of Effects
|
||||
|
||||
```tsx
|
||||
// BAD: Effects triggering each other
|
||||
function Game() {
|
||||
const [card, setCard] = useState(null)
|
||||
const [goldCardCount, setGoldCardCount] = useState(0)
|
||||
const [round, setRound] = useState(1)
|
||||
const [isGameOver, setIsGameOver] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (card?.gold) setGoldCardCount((c) => c + 1)
|
||||
}, [card])
|
||||
|
||||
useEffect(() => {
|
||||
if (goldCardCount > 3) {
|
||||
setRound((r) => r + 1)
|
||||
setGoldCardCount(0)
|
||||
}
|
||||
}, [goldCardCount])
|
||||
|
||||
useEffect(() => {
|
||||
if (round > 5) setIsGameOver(true)
|
||||
}, [round])
|
||||
}
|
||||
|
||||
// GOOD: Calculate in event handler
|
||||
function Game() {
|
||||
const [card, setCard] = useState(null)
|
||||
const [goldCardCount, setGoldCardCount] = useState(0)
|
||||
const [round, setRound] = useState(1)
|
||||
const isGameOver = round > 5 // Derived!
|
||||
|
||||
function handlePlaceCard(nextCard) {
|
||||
if (isGameOver) throw Error('Game ended')
|
||||
|
||||
setCard(nextCard)
|
||||
if (nextCard.gold) {
|
||||
if (goldCardCount < 3) {
|
||||
setGoldCardCount(goldCardCount + 1)
|
||||
} else {
|
||||
setGoldCardCount(0)
|
||||
setRound(round + 1)
|
||||
if (round === 5) alert('Good game!')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why it's bad**: Multiple re-renders (setCard -> setGoldCardCount -> setRound -> setIsGameOver). Also fragile for features like history replay.
|
||||
|
||||
---
|
||||
|
||||
## 6. Notifying Parent via Effect
|
||||
|
||||
```tsx
|
||||
// BAD: Effect to notify parent
|
||||
function Toggle({ onChange }) {
|
||||
const [isOn, setIsOn] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
onChange(isOn)
|
||||
}, [isOn, onChange])
|
||||
|
||||
function handleClick() {
|
||||
setIsOn(!isOn)
|
||||
}
|
||||
}
|
||||
|
||||
// GOOD: Notify in same event
|
||||
function Toggle({ onChange }) {
|
||||
const [isOn, setIsOn] = useState(false)
|
||||
|
||||
function updateToggle(nextIsOn) {
|
||||
setIsOn(nextIsOn)
|
||||
onChange(nextIsOn) // Same event, batched render
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
updateToggle(!isOn)
|
||||
}
|
||||
}
|
||||
|
||||
// BEST: Fully controlled component
|
||||
function Toggle({ isOn, onChange }) {
|
||||
function handleClick() {
|
||||
onChange(!isOn)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Passing Data Up to Parent
|
||||
|
||||
```tsx
|
||||
// BAD: Child fetches, passes up via Effect
|
||||
function Parent() {
|
||||
const [data, setData] = useState(null)
|
||||
return <Child onFetched={setData} />
|
||||
}
|
||||
|
||||
function Child({ onFetched }) {
|
||||
const data = useSomeAPI()
|
||||
|
||||
useEffect(() => {
|
||||
if (data) onFetched(data)
|
||||
}, [onFetched, data])
|
||||
}
|
||||
|
||||
// GOOD: Parent fetches, passes down
|
||||
function Parent() {
|
||||
const data = useSomeAPI()
|
||||
return <Child data={data} />
|
||||
}
|
||||
```
|
||||
|
||||
**Why**: Data should flow down. Upward flow via Effects makes debugging hard.
|
||||
|
||||
---
|
||||
|
||||
## 8. Fetching Without Cleanup (Race Condition)
|
||||
|
||||
```tsx
|
||||
// BAD: No cleanup - race condition
|
||||
function SearchResults({ query }) {
|
||||
const [results, setResults] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchResults(query).then((json) => {
|
||||
setResults(json) // "hello" response may arrive after "hell"
|
||||
})
|
||||
}, [query])
|
||||
}
|
||||
|
||||
// GOOD: Cleanup ignores stale responses
|
||||
function SearchResults({ query }) {
|
||||
const [results, setResults] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false
|
||||
|
||||
fetchResults(query).then((json) => {
|
||||
if (!ignore) setResults(json)
|
||||
})
|
||||
|
||||
return () => {
|
||||
ignore = true
|
||||
}
|
||||
}, [query])
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. App Initialization in Effect
|
||||
|
||||
```tsx
|
||||
// BAD: Runs twice in dev, may break auth
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
loadDataFromLocalStorage()
|
||||
checkAuthToken() // May invalidate token on second call!
|
||||
}, [])
|
||||
}
|
||||
|
||||
// GOOD: Module-level guard
|
||||
let didInit = false
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
if (!didInit) {
|
||||
didInit = true
|
||||
loadDataFromLocalStorage()
|
||||
checkAuthToken()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
// ALSO GOOD: Module-level execution
|
||||
if (typeof window !== 'undefined') {
|
||||
checkAuthToken()
|
||||
loadDataFromLocalStorage()
|
||||
}
|
||||
```
|
||||
@@ -1,95 +0,0 @@
|
||||
---
|
||||
name: typescript
|
||||
description: This skill should be used when the user asks to "optimize TypeScript performance", "speed up tsc compilation", "configure tsconfig.json", "fix type errors", "improve async patterns", or encounters TS errors (TS2322, TS2339, "is not assignable to"). Also triggers on .ts, .tsx, .d.ts file work involving type definitions, module organization, or memory management. Does NOT cover TypeScript basics, framework-specific patterns, or testing.
|
||||
---
|
||||
|
||||
# TypeScript Best Practices
|
||||
|
||||
Comprehensive performance optimization guide for TypeScript applications. Contains 45 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Reference these guidelines when:
|
||||
|
||||
- Configuring tsconfig.json for a new or existing project
|
||||
- Writing complex type definitions or generics
|
||||
- Optimizing async/await patterns and data fetching
|
||||
- Organizing modules and managing imports
|
||||
- Reviewing code for compilation or runtime performance
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Impact | Prefix |
|
||||
| -------- | ----------------------- | ----------- | ----------- |
|
||||
| 1 | Type System Performance | CRITICAL | `type-` |
|
||||
| 2 | Compiler Configuration | CRITICAL | `tscfg-` |
|
||||
| 3 | Async Patterns | HIGH | `async-` |
|
||||
| 4 | Module Organization | HIGH | `module-` |
|
||||
| 5 | Type Safety Patterns | MEDIUM-HIGH | `safety-` |
|
||||
| 6 | Memory Management | MEDIUM | `mem-` |
|
||||
| 7 | Runtime Optimization | LOW-MEDIUM | `runtime-` |
|
||||
| 8 | Advanced Patterns | LOW | `advanced-` |
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Type System Performance](references/_sections.md#1-type-system-performance) — **CRITICAL**
|
||||
- 1.1 [Add Explicit Return Types to Exported Functions](references/type-explicit-return-types.md) — CRITICAL (30-50% faster declaration emit)
|
||||
- 1.2 [Avoid Deeply Nested Generic Types](references/type-avoid-deep-generics.md) — CRITICAL (prevents exponential instantiation cost)
|
||||
- 1.3 [Avoid Large Union Types](references/type-avoid-large-unions.md) — CRITICAL (quadratic O(n²) comparison cost)
|
||||
- 1.4 [Extract Conditional Types to Named Aliases](references/type-extract-conditional-types.md) — CRITICAL (enables compiler caching, prevents re-evaluation)
|
||||
- 1.5 [Limit Type Recursion Depth](references/type-limit-recursion-depth.md) — HIGH (prevents exponential type expansion when applicable)
|
||||
- 1.6 [Prefer Interfaces Over Type Intersections](references/type-interfaces-over-intersections.md) — CRITICAL (2-5× faster type resolution)
|
||||
- 1.7 [Simplify Complex Mapped Types](references/type-simplify-mapped-types.md) — HIGH (reduces type computation by 50-80% when applicable)
|
||||
2. [Compiler Configuration](references/_sections.md#2-compiler-configuration) — **CRITICAL**
|
||||
- 2.1 [Configure Include and Exclude Properly](references/tscfg-exclude-properly.md) — CRITICAL (prevents scanning thousands of unnecessary files)
|
||||
- 2.2 [Enable Incremental Compilation](references/tscfg-enable-incremental.md) — CRITICAL (50-90% faster rebuilds)
|
||||
- 2.3 [Enable isolatedDeclarations for Parallel Declaration Emit](references/tscfg-isolated-declarations.md) — CRITICAL (enables parallel .d.ts generation without type-checker)
|
||||
- 2.4 [Enable skipLibCheck for Faster Builds](references/tscfg-skip-lib-check.md) — CRITICAL (20-40% faster compilation)
|
||||
- 2.5 [Enable strictFunctionTypes for Faster Variance Checks](references/tscfg-strict-function-types.md) — CRITICAL (enables optimized variance checking)
|
||||
- 2.6 [Use erasableSyntaxOnly for Node.js Native TypeScript](references/tscfg-erasable-syntax-only.md) — HIGH (prevents 100% of Node.js type-stripping runtime errors)
|
||||
- 2.7 [Use isolatedModules for Single-File Transpilation](references/tscfg-isolate-modules.md) — CRITICAL (80-90% faster transpilation with bundlers)
|
||||
- 2.8 [Use Project References for Large Codebases](references/tscfg-project-references.md) — CRITICAL (60-80% faster incremental builds)
|
||||
3. [Async Patterns](references/_sections.md#3-async-patterns) — **HIGH**
|
||||
- 3.1 [Annotate Async Function Return Types](references/async-explicit-return-types.md) — HIGH (prevents runtime errors, improves inference)
|
||||
- 3.2 [Avoid await Inside Loops](references/async-avoid-loop-await.md) — HIGH (N× faster for N iterations, 10 users = 10× improvement)
|
||||
- 3.3 [Avoid Unnecessary async/await](references/async-avoid-unnecessary-async.md) — HIGH (eliminates trivial Promise wrappers and improves stack traces)
|
||||
- 3.4 [Defer await Until Value Is Needed](references/async-defer-await.md) — HIGH (enables implicit parallelization)
|
||||
- 3.5 [Use Promise.all for Independent Operations](references/async-parallel-promises.md) — HIGH (2-10× improvement in I/O-bound code)
|
||||
4. [Module Organization](references/_sections.md#4-module-organization) — **HIGH**
|
||||
- 4.1 [Avoid Barrel File Imports](references/module-avoid-barrel-imports.md) — HIGH (200-800ms import cost, 30-50% larger bundles)
|
||||
- 4.2 [Avoid Circular Dependencies](references/module-avoid-circular-dependencies.md) — HIGH (prevents runtime undefined errors and slow compilation)
|
||||
- 4.3 [Control @types Package Inclusion](references/module-control-types-inclusion.md) — HIGH (prevents type conflicts and reduces memory usage)
|
||||
- 4.4 [Use Dynamic Imports for Large Modules](references/module-dynamic-imports.md) — HIGH (reduces initial bundle by 30-70%)
|
||||
- 4.5 [Use Type-Only Imports for Types](references/module-use-type-imports.md) — HIGH (eliminates runtime imports for type information)
|
||||
5. [Type Safety Patterns](references/_sections.md#5-type-safety-patterns) — **MEDIUM-HIGH**
|
||||
- 5.1 [Enable noUncheckedIndexedAccess](references/safety-no-unchecked-indexed-access.md) — MEDIUM-HIGH (prevents 100% of unchecked index access errors at compile time)
|
||||
- 5.2 [Enable strictNullChecks](references/safety-strict-null-checks.md) — MEDIUM-HIGH (prevents null/undefined runtime errors)
|
||||
- 5.3 [Prefer unknown Over any](references/safety-prefer-unknown-over-any.md) — MEDIUM-HIGH (forces type narrowing, prevents runtime errors)
|
||||
- 5.4 [Use Assertion Functions for Validation](references/safety-assertion-functions.md) — MEDIUM-HIGH (reduces validation boilerplate by 50-70%)
|
||||
- 5.5 [Use const Assertions for Literal Types](references/safety-const-assertions.md) — MEDIUM-HIGH (preserves literal types, enables better inference)
|
||||
- 5.6 [Use Exhaustive Checks for Union Types](references/safety-exhaustive-checks.md) — MEDIUM-HIGH (prevents 100% of missing case errors at compile time)
|
||||
- 5.7 [Use Type Guards for Runtime Type Checking](references/safety-use-type-guards.md) — MEDIUM-HIGH (eliminates type assertions, catches errors at boundaries)
|
||||
6. [Memory Management](references/_sections.md#6-memory-management) — **MEDIUM**
|
||||
- 6.1 [Avoid Closure Memory Leaks](references/mem-avoid-closure-leaks.md) — MEDIUM (prevents retained references in long-lived callbacks)
|
||||
- 6.2 [Avoid Global State Accumulation](references/mem-avoid-global-state.md) — MEDIUM (prevents unbounded memory growth)
|
||||
- 6.3 [Clean Up Event Listeners](references/mem-cleanup-event-listeners.md) — MEDIUM (prevents unbounded memory growth)
|
||||
- 6.4 [Clear Timers and Intervals](references/mem-clear-timers.md) — MEDIUM (prevents callback retention and repeated execution)
|
||||
- 6.5 [Use WeakMap for Object Metadata](references/mem-use-weakmap-for-metadata.md) — MEDIUM (prevents memory leaks, enables automatic cleanup)
|
||||
7. [Runtime Optimization](references/_sections.md#7-runtime-optimization) — **LOW-MEDIUM**
|
||||
- 7.1 [Avoid Object Spread in Hot Loops](references/runtime-avoid-object-spread-in-loops.md) — LOW-MEDIUM (reduces object allocations by N×)
|
||||
- 7.2 [Cache Property Access in Loops](references/runtime-cache-property-access.md) — LOW-MEDIUM (reduces property lookups by N× in hot paths)
|
||||
- 7.3 [Prefer Native Array Methods Over Lodash](references/runtime-prefer-array-methods.md) — LOW-MEDIUM (eliminates library overhead, enables tree-shaking)
|
||||
- 7.4 [Use for-of for Simple Iteration](references/runtime-use-for-of-for-iteration.md) — LOW-MEDIUM (reduces iteration boilerplate by 30-50%)
|
||||
- 7.5 [Use Modern String Methods](references/runtime-use-string-methods.md) — LOW-MEDIUM (2-5× faster than regex for simple patterns)
|
||||
- 7.6 [Use Set/Map for O(1) Lookups](references/runtime-use-set-for-lookups.md) — LOW-MEDIUM (O(n) to O(1) per lookup)
|
||||
8. [Advanced Patterns](references/_sections.md#8-advanced-patterns) — **LOW**
|
||||
- 8.1 [Use Branded Types for Type-Safe IDs](references/advanced-branded-types.md) — LOW (prevents mixing incompatible ID types)
|
||||
- 8.2 [Use satisfies for Type Validation with Inference](references/advanced-satisfies-operator.md) — LOW (prevents property access errors, enables 100% autocomplete accuracy)
|
||||
- 8.3 [Use Template Literal Types for String Patterns](references/advanced-template-literal-types.md) — LOW (prevents 100% of string format errors at compile time)
|
||||
|
||||
## References
|
||||
|
||||
1. [https://github.com/microsoft/TypeScript/wiki/Performance](https://github.com/microsoft/TypeScript/wiki/Performance)
|
||||
2. [https://www.typescriptlang.org/docs/handbook/](https://www.typescriptlang.org/docs/handbook/)
|
||||
3. [https://v8.dev/blog](https://v8.dev/blog)
|
||||
4. [https://nodejs.org/en/learn/diagnostics/memory](https://nodejs.org/en/learn/diagnostics/memory)
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
title: Rule Title Here
|
||||
impact: MEDIUM
|
||||
impactDescription: Quantified impact (e.g., "2-10× improvement", "200ms savings")
|
||||
tags: prefix, technique, related-concepts
|
||||
---
|
||||
|
||||
## Rule Title Here
|
||||
|
||||
Brief explanation of the rule and why it matters (1-3 sentences). Focus on performance implications.
|
||||
|
||||
**Incorrect (description of what's wrong):**
|
||||
|
||||
```typescript
|
||||
// Bad code example here
|
||||
const badExample = inefficientOperation()
|
||||
```
|
||||
|
||||
**Correct (description of what's right):**
|
||||
|
||||
```typescript
|
||||
// Good code example here
|
||||
const goodExample = efficientOperation()
|
||||
```
|
||||
|
||||
Reference: [Link to documentation](https://example.com)
|
||||
@@ -1,46 +0,0 @@
|
||||
# Sections
|
||||
|
||||
This file defines all sections, their ordering, impact levels, and descriptions.
|
||||
The section ID (in parentheses) is the filename prefix used to group rules.
|
||||
|
||||
---
|
||||
|
||||
## 1. Type System Performance (type)
|
||||
|
||||
**Impact:** CRITICAL
|
||||
**Description:** Complex types, deep generics, and large unions cause quadratic compilation time. Simplifying type definitions yields the largest compile-time gains.
|
||||
|
||||
## 2. Compiler Configuration (tscfg)
|
||||
|
||||
**Impact:** CRITICAL
|
||||
**Description:** Misconfigured tsconfig causes full rebuilds and unnecessary file scanning. Proper configuration reduces compile time by 50-80%.
|
||||
|
||||
## 3. Async Patterns (async)
|
||||
|
||||
**Impact:** HIGH
|
||||
**Description:** Sequential awaits create runtime waterfalls. Parallelizing async operations yields 2-10× improvement in I/O-bound code.
|
||||
|
||||
## 4. Module Organization (module)
|
||||
|
||||
**Impact:** HIGH
|
||||
**Description:** Barrel files and circular dependencies force excessive module loading. Direct imports reduce bundle size and improve tree-shaking.
|
||||
|
||||
## 5. Type Safety Patterns (safety)
|
||||
|
||||
**Impact:** MEDIUM-HIGH
|
||||
**Description:** Type guards, narrowing, and strict mode prevent runtime errors. Proper patterns eliminate defensive runtime checks.
|
||||
|
||||
## 6. Memory Management (mem)
|
||||
|
||||
**Impact:** MEDIUM
|
||||
**Description:** Object pooling, WeakMap usage, and closure hygiene reduce GC pressure and memory leaks in long-running applications.
|
||||
|
||||
## 7. Runtime Optimization (runtime)
|
||||
|
||||
**Impact:** LOW-MEDIUM
|
||||
**Description:** Loop optimization, property caching, and collection choice improve hot-path performance.
|
||||
|
||||
## 8. Advanced Patterns (advanced)
|
||||
|
||||
**Impact:** LOW
|
||||
**Description:** Branded types, variance annotations, and declaration merging for specialized use cases.
|
||||
@@ -1,92 +0,0 @@
|
||||
---
|
||||
title: Use Branded Types for Type-Safe IDs
|
||||
impact: LOW
|
||||
impactDescription: prevents mixing incompatible ID types
|
||||
tags: advanced, branded-types, nominal-types, type-safety, ids
|
||||
---
|
||||
|
||||
## Use Branded Types for Type-Safe IDs
|
||||
|
||||
TypeScript uses structural typing, so `string` types are interchangeable even when they represent different concepts. Branded types add a unique marker to prevent mixing incompatible values.
|
||||
|
||||
**Incorrect (structural typing allows mixing):**
|
||||
|
||||
```typescript
|
||||
type UserId = string
|
||||
type OrderId = string
|
||||
type ProductId = string
|
||||
|
||||
function fetchUser(id: UserId): Promise<User> {
|
||||
/* ... */
|
||||
}
|
||||
function fetchOrder(id: OrderId): Promise<Order> {
|
||||
/* ... */
|
||||
}
|
||||
|
||||
const userId: UserId = 'user-123'
|
||||
const orderId: OrderId = 'order-456'
|
||||
|
||||
// No error - all strings are interchangeable
|
||||
fetchUser(orderId) // Bug: passed OrderId to UserId parameter
|
||||
fetchOrder(userId) // Bug: passed UserId to OrderId parameter
|
||||
```
|
||||
|
||||
**Correct (branded types prevent mixing):**
|
||||
|
||||
```typescript
|
||||
type Brand<K, T> = K & { __brand: T }
|
||||
|
||||
type UserId = Brand<string, 'UserId'>
|
||||
type OrderId = Brand<string, 'OrderId'>
|
||||
type ProductId = Brand<string, 'ProductId'>
|
||||
|
||||
function createUserId(id: string): UserId {
|
||||
return id as UserId
|
||||
}
|
||||
|
||||
function createOrderId(id: string): OrderId {
|
||||
return id as OrderId
|
||||
}
|
||||
|
||||
function fetchUser(id: UserId): Promise<User> {
|
||||
/* ... */
|
||||
}
|
||||
function fetchOrder(id: OrderId): Promise<Order> {
|
||||
/* ... */
|
||||
}
|
||||
|
||||
const userId = createUserId('user-123')
|
||||
const orderId = createOrderId('order-456')
|
||||
|
||||
fetchUser(orderId) // Error: Argument of type 'OrderId' is not assignable to 'UserId'
|
||||
fetchOrder(userId) // Error: Argument of type 'UserId' is not assignable to 'OrderId'
|
||||
fetchUser(userId) // OK
|
||||
```
|
||||
|
||||
**For numeric types:**
|
||||
|
||||
```typescript
|
||||
type Cents = Brand<number, 'Cents'>
|
||||
type Dollars = Brand<number, 'Dollars'>
|
||||
|
||||
function toCents(dollars: Dollars): Cents {
|
||||
return (dollars * 100) as Cents
|
||||
}
|
||||
|
||||
function formatPrice(cents: Cents): string {
|
||||
return `$${(cents / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
const price = 29.99 as Dollars
|
||||
formatPrice(price) // Error: Dollars not assignable to Cents
|
||||
formatPrice(toCents(price)) // OK: '$29.99'
|
||||
```
|
||||
|
||||
**When to use branded types:**
|
||||
|
||||
- Entity IDs that shouldn't be mixed
|
||||
- Currency/unit conversions
|
||||
- Validated strings (email, URL, slug)
|
||||
- Sensitive data that needs tracking
|
||||
|
||||
Reference: [TypeScript Playground - Nominal Typing](https://www.typescriptlang.org/play/typescript/language-extensions/nominal-typing.ts.html)
|
||||
@@ -1,95 +0,0 @@
|
||||
---
|
||||
title: Use satisfies for Type Validation with Inference
|
||||
impact: LOW
|
||||
impactDescription: prevents property access errors, enables 100% autocomplete accuracy
|
||||
tags: advanced, satisfies, inference, validation, type-checking
|
||||
---
|
||||
|
||||
## Use satisfies for Type Validation with Inference
|
||||
|
||||
The `satisfies` operator validates that a value conforms to a type while preserving the narrower inferred type. This gives you both type safety and precise autocomplete.
|
||||
|
||||
**Incorrect (type annotation loses literal types):**
|
||||
|
||||
```typescript
|
||||
type ColorConfig = Record<string, [number, number, number]>
|
||||
|
||||
const colors: ColorConfig = {
|
||||
red: [255, 0, 0],
|
||||
green: [0, 255, 0],
|
||||
blue: [0, 0, 255]
|
||||
// Can't access colors.red - it's just string keys
|
||||
}
|
||||
|
||||
// TypeScript doesn't know 'red' is a valid key
|
||||
const redValue = colors.red // Type: [number, number, number]
|
||||
const pinkValue = colors.pink // No error! Type: [number, number, number]
|
||||
```
|
||||
|
||||
**Correct (satisfies preserves literal types):**
|
||||
|
||||
```typescript
|
||||
type ColorConfig = Record<string, [number, number, number]>
|
||||
|
||||
const colors = {
|
||||
red: [255, 0, 0],
|
||||
green: [0, 255, 0],
|
||||
blue: [0, 0, 255]
|
||||
} satisfies ColorConfig
|
||||
|
||||
// TypeScript knows exact keys
|
||||
const redValue = colors.red // Type: [number, number, number]
|
||||
const pinkValue = colors.pink // Error: Property 'pink' does not exist
|
||||
```
|
||||
|
||||
**For configuration objects:**
|
||||
|
||||
```typescript
|
||||
interface Route {
|
||||
path: string
|
||||
component: () => JSX.Element
|
||||
auth?: boolean
|
||||
}
|
||||
|
||||
// Without satisfies - loses literal path types
|
||||
const routes: Route[] = [
|
||||
{ path: '/', component: Home },
|
||||
{ path: '/users', component: Users }
|
||||
]
|
||||
// routes[0].path is just 'string'
|
||||
|
||||
// With satisfies - preserves literal paths
|
||||
const routes = [
|
||||
{ path: '/', component: Home },
|
||||
{ path: '/users', component: Users }
|
||||
] satisfies Route[]
|
||||
// routes[0].path is '/'
|
||||
|
||||
type RoutePath = (typeof routes)[number]['path'] // '/' | '/users'
|
||||
```
|
||||
|
||||
**Combining with as const:**
|
||||
|
||||
```typescript
|
||||
const config = {
|
||||
apiUrl: 'https://api.example.com',
|
||||
timeout: 5000,
|
||||
retries: 3
|
||||
} as const satisfies {
|
||||
apiUrl: string
|
||||
timeout: number
|
||||
retries: number
|
||||
}
|
||||
|
||||
// Both validated AND readonly with literal types
|
||||
config.apiUrl // Type: 'https://api.example.com' (not just string)
|
||||
config.timeout = 3000 // Error: Cannot assign to 'timeout' (readonly)
|
||||
```
|
||||
|
||||
**When to use satisfies vs type annotation:**
|
||||
|
||||
- Use `satisfies` when you want validation but need literal types
|
||||
- Use type annotation (`:`) when you want the variable to be exactly that type
|
||||
- Use `as const satisfies` for readonly config with validation
|
||||
|
||||
Reference: [TypeScript 4.9 satisfies](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#the-satisfies-operator)
|
||||
@@ -1,105 +0,0 @@
|
||||
---
|
||||
title: Use Template Literal Types for String Patterns
|
||||
impact: LOW
|
||||
impactDescription: prevents 100% of string format errors at compile time
|
||||
tags: advanced, template-literals, string-types, patterns, validation
|
||||
---
|
||||
|
||||
## Use Template Literal Types for String Patterns
|
||||
|
||||
Template literal types allow defining string patterns at the type level. TypeScript validates that strings match the expected format at compile time.
|
||||
|
||||
**Incorrect (plain string allows any value):**
|
||||
|
||||
```typescript
|
||||
type EventHandler = {
|
||||
event: string
|
||||
handler: () => void
|
||||
}
|
||||
|
||||
const handler: EventHandler = {
|
||||
event: 'click', // OK
|
||||
handler: () => {}
|
||||
}
|
||||
|
||||
const badHandler: EventHandler = {
|
||||
event: 'clck', // Typo - no error
|
||||
handler: () => {}
|
||||
}
|
||||
|
||||
function addEventListener(event: string, handler: () => void): void {}
|
||||
addEventListener('onlcick', () => {}) // Typo compiles fine
|
||||
```
|
||||
|
||||
**Correct (template literal type validates pattern):**
|
||||
|
||||
```typescript
|
||||
type DOMEvent = 'click' | 'focus' | 'blur' | 'submit' | 'change'
|
||||
type EventHandlerName = `on${Capitalize<DOMEvent>}`
|
||||
|
||||
type EventHandler = {
|
||||
event: EventHandlerName
|
||||
handler: () => void
|
||||
}
|
||||
|
||||
const handler: EventHandler = {
|
||||
event: 'onClick', // OK
|
||||
handler: () => {}
|
||||
}
|
||||
|
||||
const badHandler: EventHandler = {
|
||||
event: 'onClck', // Error: Type '"onClck"' is not assignable to type 'EventHandlerName'
|
||||
handler: () => {}
|
||||
}
|
||||
```
|
||||
|
||||
**For CSS-like patterns:**
|
||||
|
||||
```typescript
|
||||
type CSSUnit = 'px' | 'em' | 'rem' | '%' | 'vh' | 'vw'
|
||||
type CSSValue = `${number}${CSSUnit}`
|
||||
|
||||
function setWidth(element: HTMLElement, width: CSSValue): void {
|
||||
element.style.width = width
|
||||
}
|
||||
|
||||
setWidth(div, '100px') // OK
|
||||
setWidth(div, '2.5rem') // OK
|
||||
setWidth(div, '100') // Error: Type '"100"' is not assignable to type 'CSSValue'
|
||||
setWidth(div, '100pixels') // Error
|
||||
```
|
||||
|
||||
**For API route patterns:**
|
||||
|
||||
```typescript
|
||||
type APIVersion = 'v1' | 'v2'
|
||||
type Resource = 'users' | 'orders' | 'products'
|
||||
type APIRoute = `/api/${APIVersion}/${Resource}`
|
||||
|
||||
function fetchResource(route: APIRoute): Promise<Response> {
|
||||
return fetch(route)
|
||||
}
|
||||
|
||||
fetchResource('/api/v1/users') // OK
|
||||
fetchResource('/api/v2/orders') // OK
|
||||
fetchResource('/api/v3/users') // Error: 'v3' not in APIVersion
|
||||
fetchResource('/users') // Error: doesn't match pattern
|
||||
```
|
||||
|
||||
**Combining with mapped types:**
|
||||
|
||||
```typescript
|
||||
type Getters<T> = {
|
||||
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
|
||||
}
|
||||
|
||||
interface User {
|
||||
name: string
|
||||
age: number
|
||||
}
|
||||
|
||||
type UserGetters = Getters<User>
|
||||
// { getName: () => string; getAge: () => number }
|
||||
```
|
||||
|
||||
Reference: [TypeScript 4.1 Template Literal Types](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html)
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
title: Avoid await Inside Loops
|
||||
impact: HIGH
|
||||
impactDescription: N× faster for N iterations, 10 users = 10× improvement
|
||||
tags: async, loops, batching, waterfalls, performance
|
||||
---
|
||||
|
||||
## Avoid await Inside Loops
|
||||
|
||||
Using `await` inside a loop creates N sequential operations. Collect promises and await them together, or use `Promise.all()` with `map()` for parallel execution.
|
||||
|
||||
**Incorrect (N sequential requests):**
|
||||
|
||||
```typescript
|
||||
async function enrichUsers(userIds: string[]): Promise<EnrichedUser[]> {
|
||||
const enrichedUsers: EnrichedUser[] = []
|
||||
|
||||
for (const userId of userIds) {
|
||||
const user = await fetchUser(userId) // Waits for each request
|
||||
const profile = await fetchProfile(userId)
|
||||
enrichedUsers.push({ ...user, profile })
|
||||
}
|
||||
// 10 users × 2 requests × 100ms = 2000ms
|
||||
|
||||
return enrichedUsers
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (parallel execution):**
|
||||
|
||||
```typescript
|
||||
async function enrichUsers(userIds: string[]): Promise<EnrichedUser[]> {
|
||||
const enrichedUsers = await Promise.all(
|
||||
userIds.map(async (userId) => {
|
||||
const [user, profile] = await Promise.all([fetchUser(userId), fetchProfile(userId)])
|
||||
return { ...user, profile }
|
||||
})
|
||||
)
|
||||
// 10 users processed in parallel = 100ms total
|
||||
|
||||
return enrichedUsers
|
||||
}
|
||||
```
|
||||
|
||||
**For rate-limited APIs (chunked batching):**
|
||||
|
||||
```typescript
|
||||
async function enrichUsers(userIds: string[]): Promise<EnrichedUser[]> {
|
||||
const BATCH_SIZE = 5
|
||||
const results: EnrichedUser[] = []
|
||||
|
||||
for (let i = 0; i < userIds.length; i += BATCH_SIZE) {
|
||||
const batch = userIds.slice(i, i + BATCH_SIZE)
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (userId) => {
|
||||
const [user, profile] = await Promise.all([fetchUser(userId), fetchProfile(userId)])
|
||||
return { ...user, profile }
|
||||
})
|
||||
)
|
||||
results.push(...batchResults)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
```
|
||||
|
||||
**When sequential loop await is acceptable:**
|
||||
|
||||
- Each iteration depends on the previous result
|
||||
- API strictly requires sequential calls
|
||||
- Processing order affects correctness
|
||||
|
||||
Reference: [ESLint no-await-in-loop](https://eslint.org/docs/rules/no-await-in-loop)
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
title: Avoid Unnecessary async/await
|
||||
impact: HIGH
|
||||
impactDescription: eliminates trivial Promise wrappers and improves stack traces
|
||||
tags: async, promises, overhead, optimization, return-await
|
||||
---
|
||||
|
||||
## Avoid Unnecessary async/await
|
||||
|
||||
Remove `async` from functions that only wrap a single Promise without using `await` for control flow. However, prefer `return await` over bare `return` inside try/catch blocks — it ensures errors are caught and produces better stack traces.
|
||||
|
||||
**Incorrect (trivial async wrapper with no logic):**
|
||||
|
||||
```typescript
|
||||
async function getUser(userId: string): Promise<User> {
|
||||
return userRepository.findById(userId)
|
||||
// async keyword creates unnecessary Promise wrapper
|
||||
// No await, no try/catch — async adds nothing here
|
||||
}
|
||||
|
||||
async function deleteUser(userId: string): Promise<void> {
|
||||
return userRepository.delete(userId)
|
||||
// Same pattern — async is pure overhead
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (remove async when it adds nothing):**
|
||||
|
||||
```typescript
|
||||
function getUser(userId: string): Promise<User> {
|
||||
return userRepository.findById(userId)
|
||||
// Direct Promise return, no wrapper
|
||||
}
|
||||
|
||||
function deleteUser(userId: string): Promise<void> {
|
||||
return userRepository.delete(userId)
|
||||
}
|
||||
```
|
||||
|
||||
**Keep async + return await in try/catch:**
|
||||
|
||||
```typescript
|
||||
// Correct — return await ensures the error is caught
|
||||
async function getUser(userId: string): Promise<User> {
|
||||
try {
|
||||
return await userRepository.findById(userId)
|
||||
// Without await, rejected promise skips the catch block
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch user', { userId, error })
|
||||
throw new UserNotFoundError(userId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When async IS needed:**
|
||||
|
||||
- Multiple sequential await statements
|
||||
- Try/catch around await (use `return await` here)
|
||||
- Conditional await logic
|
||||
- Complex control flow with early returns
|
||||
|
||||
**Note:** ESLint deprecated `no-return-await` in v8.46.0 because `return await` is both safe and produces better stack traces. Use the `@typescript-eslint/return-await` rule with `in-try-catch` setting for nuanced control.
|
||||
|
||||
Reference: [ESLint no-return-await deprecation](https://eslint.org/docs/latest/rules/no-return-await)
|
||||
@@ -1,71 +0,0 @@
|
||||
---
|
||||
title: Defer await Until Value Is Needed
|
||||
impact: HIGH
|
||||
impactDescription: enables implicit parallelization
|
||||
tags: async, defer, promises, optimization, performance
|
||||
---
|
||||
|
||||
## Defer await Until Value Is Needed
|
||||
|
||||
Start async operations immediately but defer `await` until the value is actually required. This allows independent work to proceed while promises resolve in the background.
|
||||
|
||||
**Incorrect (blocks immediately, serializes independent work):**
|
||||
|
||||
```typescript
|
||||
async function processOrder(orderId: string): Promise<OrderResult> {
|
||||
const order = await fetchOrder(orderId) // Blocks here
|
||||
const config = await loadProcessingConfig() // Waits for order first, unnecessarily
|
||||
|
||||
// config doesn't depend on order — these could run in parallel
|
||||
if (order.priority === 'express') {
|
||||
return processExpress(order, config)
|
||||
}
|
||||
return processStandard(order, config)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (deferred await):**
|
||||
|
||||
```typescript
|
||||
async function processOrder(orderId: string): Promise<OrderResult> {
|
||||
const orderPromise = fetchOrder(orderId) // Start immediately
|
||||
const config = await loadProcessingConfig() // Runs while order fetches
|
||||
|
||||
const order = await orderPromise // Now await when needed
|
||||
|
||||
if (order.priority === 'express') {
|
||||
return processExpress(order, config)
|
||||
}
|
||||
return processStandard(order, config)
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern for dependent-then-independent operations:**
|
||||
|
||||
```typescript
|
||||
async function loadUserContent(userId: string): Promise<Content> {
|
||||
// Start user fetch (needed for dependent calls)
|
||||
const userPromise = fetchUser(userId)
|
||||
|
||||
// Start independent operations immediately
|
||||
const settingsPromise = fetchGlobalSettings()
|
||||
const featuresPromise = fetchFeatureFlags()
|
||||
|
||||
// Await user for dependent operations
|
||||
const user = await userPromise
|
||||
const ordersPromise = fetchOrders(user.id)
|
||||
const prefsPromise = fetchPreferences(user.id)
|
||||
|
||||
// Await all remaining
|
||||
const [settings, features, orders, prefs] = await Promise.all([
|
||||
settingsPromise,
|
||||
featuresPromise,
|
||||
ordersPromise,
|
||||
prefsPromise
|
||||
])
|
||||
|
||||
return { user, settings, features, orders, prefs }
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [V8 Blog - Fast Async](https://v8.dev/blog/fast-async)
|
||||
@@ -1,77 +0,0 @@
|
||||
---
|
||||
title: Annotate Async Function Return Types
|
||||
impact: HIGH
|
||||
impactDescription: prevents runtime errors, improves inference
|
||||
tags: async, return-types, promises, type-safety, inference
|
||||
---
|
||||
|
||||
## Annotate Async Function Return Types
|
||||
|
||||
Explicit return types on async functions catch mismatches at the function boundary rather than at call sites. They also improve IDE performance by avoiding full function body inference.
|
||||
|
||||
**Incorrect (inferred Promise type):**
|
||||
|
||||
```typescript
|
||||
async function fetchUserOrders(userId: string) {
|
||||
const response = await fetch(`/api/users/${userId}/orders`)
|
||||
if (!response.ok) {
|
||||
return null // Implicit: Promise<Order[] | null>
|
||||
}
|
||||
return response.json() // Implicit: Promise<any>
|
||||
}
|
||||
|
||||
// Caller has unclear type: Promise<any>
|
||||
const orders = await fetchUserOrders('123')
|
||||
orders.map((o) => o.id) // No type error even if orders is null
|
||||
```
|
||||
|
||||
**Correct (explicit Promise type):**
|
||||
|
||||
```typescript
|
||||
interface Order {
|
||||
id: string
|
||||
total: number
|
||||
status: OrderStatus
|
||||
}
|
||||
|
||||
async function fetchUserOrders(userId: string): Promise<Order[] | null> {
|
||||
const response = await fetch(`/api/users/${userId}/orders`)
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
return response.json() as Promise<Order[]>
|
||||
}
|
||||
|
||||
// Caller knows the exact type
|
||||
const orders = await fetchUserOrders('123')
|
||||
if (orders) {
|
||||
orders.map((o) => o.id) // Type-safe access
|
||||
}
|
||||
```
|
||||
|
||||
**For functions that might throw:**
|
||||
|
||||
```typescript
|
||||
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E }
|
||||
|
||||
async function fetchUserOrders(userId: string): Promise<Result<Order[]>> {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}/orders`)
|
||||
if (!response.ok) {
|
||||
return { ok: false, error: new Error(`HTTP ${response.status}`) }
|
||||
}
|
||||
const orders = (await response.json()) as Order[]
|
||||
return { ok: true, value: orders }
|
||||
} catch (error) {
|
||||
return { ok: false, error: error as Error }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Errors caught at function definition, not call sites
|
||||
- Better IDE autocomplete for consumers
|
||||
- Self-documenting API contracts
|
||||
|
||||
Reference: [TypeScript Performance Wiki - Using Type Annotations](https://github.com/microsoft/TypeScript/wiki/Performance#using-type-annotations)
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
title: Use Promise.all for Independent Operations
|
||||
impact: HIGH
|
||||
impactDescription: 2-10× improvement in I/O-bound code
|
||||
tags: async, promises, parallel, waterfalls, performance
|
||||
---
|
||||
|
||||
## Use Promise.all for Independent Operations
|
||||
|
||||
Sequential `await` statements create request waterfalls—each operation waits for the previous one to complete. Use `Promise.all()` to execute independent async operations concurrently.
|
||||
|
||||
**Incorrect (sequential execution, N round trips):**
|
||||
|
||||
```typescript
|
||||
async function loadDashboard(userId: string): Promise<Dashboard> {
|
||||
const user = await fetchUser(userId) // 200ms
|
||||
const orders = await fetchOrders(userId) // 300ms
|
||||
const notifications = await fetchNotifications(userId) // 150ms
|
||||
// Total: 650ms (sequential)
|
||||
|
||||
return { user, orders, notifications }
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (parallel execution, wall-clock time = max latency):**
|
||||
|
||||
```typescript
|
||||
async function loadDashboard(userId: string): Promise<Dashboard> {
|
||||
const [user, orders, notifications] = await Promise.all([
|
||||
fetchUser(userId), // 200ms ─┐
|
||||
fetchOrders(userId), // 300ms ─┼─ Run in parallel
|
||||
fetchNotifications(userId) // 150ms ─┘
|
||||
])
|
||||
// Total: 300ms (max of all operations)
|
||||
|
||||
return { user, orders, notifications }
|
||||
}
|
||||
```
|
||||
|
||||
**For error handling with partial success:**
|
||||
|
||||
```typescript
|
||||
async function loadDashboard(userId: string): Promise<Dashboard> {
|
||||
const results = await Promise.allSettled([
|
||||
fetchUser(userId),
|
||||
fetchOrders(userId),
|
||||
fetchNotifications(userId)
|
||||
])
|
||||
|
||||
return {
|
||||
user: results[0].status === 'fulfilled' ? results[0].value : null,
|
||||
orders: results[1].status === 'fulfilled' ? results[1].value : [],
|
||||
notifications: results[2].status === 'fulfilled' ? results[2].value : []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When sequential is correct:**
|
||||
|
||||
- Operations have data dependencies (need result A to make request B)
|
||||
- Rate limiting requires sequential requests
|
||||
- Order of execution matters for side effects
|
||||
|
||||
Reference: [MDN Promise.all](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all)
|
||||
@@ -1,100 +0,0 @@
|
||||
---
|
||||
title: Avoid Closure Memory Leaks
|
||||
impact: MEDIUM
|
||||
impactDescription: prevents retained references in long-lived callbacks
|
||||
tags: mem, closures, memory-leaks, callbacks, garbage-collection
|
||||
---
|
||||
|
||||
## Avoid Closure Memory Leaks
|
||||
|
||||
Closures retain references to their outer scope variables. Long-lived callbacks can accidentally keep large objects alive, causing memory to grow unboundedly.
|
||||
|
||||
**Incorrect (closure retains entire scope):**
|
||||
|
||||
```typescript
|
||||
function createDataProcessor(largeDataset: DataRecord[]): () => void {
|
||||
const processedIds = new Set<string>()
|
||||
|
||||
return function processNext(): void {
|
||||
// This closure retains reference to largeDataset
|
||||
// even though it only needs processedIds
|
||||
const next = largeDataset.find((r) => !processedIds.has(r.id))
|
||||
if (next) {
|
||||
processedIds.add(next.id)
|
||||
sendToServer(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// largeDataset (100MB) stays in memory as long as processNext exists
|
||||
const processor = createDataProcessor(hugeDataset)
|
||||
setInterval(processor, 1000) // Runs forever, 100MB never freed
|
||||
```
|
||||
|
||||
**Correct (closure captures only what it needs):**
|
||||
|
||||
```typescript
|
||||
function createDataProcessor(largeDataset: DataRecord[]): () => void {
|
||||
// Build a queue of just the IDs and a lookup for individual records
|
||||
const pendingQueue: string[] = largeDataset.map((r) => r.id)
|
||||
const getRecord = (id: string): DataRecord | undefined => largeDataset.find((r) => r.id === id)
|
||||
|
||||
// Release the array reference — closure only captures pendingQueue and getRecord
|
||||
// Caller should also release their reference to largeDataset
|
||||
return function processNext(): void {
|
||||
const nextId = pendingQueue.shift()
|
||||
if (nextId) {
|
||||
const record = getRecord(nextId)
|
||||
if (record) sendToServer(record)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Better pattern — accept an iterator to avoid holding the full dataset:**
|
||||
|
||||
```typescript
|
||||
function createDataProcessor(records: Iterable<DataRecord>): () => void {
|
||||
const iterator = records[Symbol.iterator]()
|
||||
|
||||
return function processNext(): void {
|
||||
const { value, done } = iterator.next()
|
||||
if (!done) {
|
||||
sendToServer(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**For event handlers:**
|
||||
|
||||
```typescript
|
||||
// Incorrect - handler retains component instance forever
|
||||
class Dashboard {
|
||||
private largeCache: Map<string, Data> = new Map()
|
||||
|
||||
initialize(): void {
|
||||
window.addEventListener('resize', () => {
|
||||
this.handleResize() // 'this' keeps entire Dashboard alive
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Correct - remove listener when done
|
||||
class Dashboard {
|
||||
private largeCache: Map<string, Data> = new Map()
|
||||
private resizeHandler: () => void
|
||||
|
||||
initialize(): void {
|
||||
this.resizeHandler = () => this.handleResize()
|
||||
window.addEventListener('resize', this.resizeHandler)
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
window.removeEventListener('resize', this.resizeHandler)
|
||||
this.largeCache.clear()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [Node.js Memory Diagnostics](https://nodejs.org/en/learn/diagnostics/memory)
|
||||
@@ -1,97 +0,0 @@
|
||||
---
|
||||
title: Avoid Global State Accumulation
|
||||
impact: MEDIUM
|
||||
impactDescription: prevents unbounded memory growth
|
||||
tags: mem, global-state, singletons, memory-leaks, caching
|
||||
---
|
||||
|
||||
## Avoid Global State Accumulation
|
||||
|
||||
Global variables and module-level state persist for the application's lifetime. Unbounded caches or collections at module scope grow indefinitely, causing memory exhaustion.
|
||||
|
||||
**Incorrect (unbounded global cache):**
|
||||
|
||||
```typescript
|
||||
// cache.ts
|
||||
const userCache = new Map<string, User>() // Never cleared
|
||||
|
||||
export function getCachedUser(id: string): User | undefined {
|
||||
return userCache.get(id)
|
||||
}
|
||||
|
||||
export function cacheUser(user: User): void {
|
||||
userCache.set(user.id, user)
|
||||
// Cache grows forever, never evicts old entries
|
||||
}
|
||||
|
||||
// After 1 million users, cache holds 1 million User objects
|
||||
```
|
||||
|
||||
**Correct (bounded cache with eviction):**
|
||||
|
||||
```typescript
|
||||
// cache.ts
|
||||
class LRUCache<K, V> {
|
||||
private cache = new Map<K, V>()
|
||||
private maxSize: number
|
||||
|
||||
constructor(maxSize: number) {
|
||||
this.maxSize = maxSize
|
||||
}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
const value = this.cache.get(key)
|
||||
if (value !== undefined) {
|
||||
// Move to end (most recently used)
|
||||
this.cache.delete(key)
|
||||
this.cache.set(key, value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
if (this.cache.has(key)) {
|
||||
this.cache.delete(key)
|
||||
} else if (this.cache.size >= this.maxSize) {
|
||||
// Evict oldest (first) entry
|
||||
const oldest = this.cache.keys().next().value
|
||||
this.cache.delete(oldest)
|
||||
}
|
||||
this.cache.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
const userCache = new LRUCache<string, User>(1000) // Max 1000 entries
|
||||
|
||||
export function getCachedUser(id: string): User | undefined {
|
||||
return userCache.get(id)
|
||||
}
|
||||
|
||||
export function cacheUser(user: User): void {
|
||||
userCache.set(user.id, user)
|
||||
}
|
||||
```
|
||||
|
||||
**For request-scoped state (Node.js):**
|
||||
|
||||
```typescript
|
||||
import { AsyncLocalStorage } from 'async_hooks'
|
||||
|
||||
interface RequestContext {
|
||||
userId: string
|
||||
cache: Map<string, unknown>
|
||||
}
|
||||
|
||||
const requestContext = new AsyncLocalStorage<RequestContext>()
|
||||
|
||||
export function runWithContext<T>(context: RequestContext, fn: () => T): T {
|
||||
return requestContext.run(context, fn)
|
||||
// Context is automatically cleaned up when request ends
|
||||
}
|
||||
|
||||
export function getRequestCache(): Map<string, unknown> {
|
||||
return requestContext.getStore()?.cache ?? new Map()
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [Node.js Memory Management](https://nodejs.org/en/learn/diagnostics/memory)
|
||||
@@ -1,111 +0,0 @@
|
||||
---
|
||||
title: Clean Up Event Listeners
|
||||
impact: MEDIUM
|
||||
impactDescription: prevents unbounded memory growth
|
||||
tags: mem, event-listeners, cleanup, memory-leaks, lifecycle
|
||||
---
|
||||
|
||||
## Clean Up Event Listeners
|
||||
|
||||
Event listeners hold references to their callback functions and bound objects. Failing to remove them when components unmount causes memory to grow with each mount/unmount cycle.
|
||||
|
||||
**Incorrect (listeners never removed):**
|
||||
|
||||
```typescript
|
||||
class WebSocketManager {
|
||||
private socket: WebSocket
|
||||
|
||||
connect(url: string): void {
|
||||
this.socket = new WebSocket(url)
|
||||
|
||||
this.socket.addEventListener('message', (event) => {
|
||||
this.handleMessage(event.data)
|
||||
})
|
||||
|
||||
this.socket.addEventListener('error', (event) => {
|
||||
this.handleError(event)
|
||||
})
|
||||
// Listeners keep 'this' alive even after disconnect
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.socket.close()
|
||||
// Listeners still attached, WebSocketManager can't be GC'd
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (listeners removed on cleanup):**
|
||||
|
||||
```typescript
|
||||
class WebSocketManager {
|
||||
private socket: WebSocket
|
||||
private messageHandler: (event: MessageEvent) => void
|
||||
private errorHandler: (event: Event) => void
|
||||
|
||||
connect(url: string): void {
|
||||
this.socket = new WebSocket(url)
|
||||
|
||||
this.messageHandler = (event) => this.handleMessage(event.data)
|
||||
this.errorHandler = (event) => this.handleError(event)
|
||||
|
||||
this.socket.addEventListener('message', this.messageHandler)
|
||||
this.socket.addEventListener('error', this.errorHandler)
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.socket.removeEventListener('message', this.messageHandler)
|
||||
this.socket.removeEventListener('error', this.errorHandler)
|
||||
this.socket.close()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Using AbortController (modern pattern):**
|
||||
|
||||
```typescript
|
||||
class WebSocketManager {
|
||||
private socket: WebSocket
|
||||
private abortController: AbortController
|
||||
|
||||
connect(url: string): void {
|
||||
this.abortController = new AbortController()
|
||||
const { signal } = this.abortController
|
||||
|
||||
this.socket = new WebSocket(url)
|
||||
|
||||
this.socket.addEventListener('message', (e) => this.handleMessage(e.data), { signal })
|
||||
this.socket.addEventListener('error', (e) => this.handleError(e), { signal })
|
||||
// All listeners automatically removed when signal is aborted
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.abortController.abort() // Removes all listeners at once
|
||||
this.socket.close()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**React useEffect pattern:**
|
||||
|
||||
```typescript
|
||||
function useWebSocket(url: string): Data | null {
|
||||
const [data, setData] = useState<Data | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const socket = new WebSocket(url)
|
||||
const handler = (event: MessageEvent) => setData(JSON.parse(event.data))
|
||||
|
||||
socket.addEventListener('message', handler)
|
||||
|
||||
return () => {
|
||||
socket.removeEventListener('message', handler)
|
||||
socket.close()
|
||||
}
|
||||
}, [url])
|
||||
|
||||
return data
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [MDN AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)
|
||||
@@ -1,106 +0,0 @@
|
||||
---
|
||||
title: Clear Timers and Intervals
|
||||
impact: MEDIUM
|
||||
impactDescription: prevents callback retention and repeated execution
|
||||
tags: mem, timers, intervals, cleanup, memory-leaks
|
||||
---
|
||||
|
||||
## Clear Timers and Intervals
|
||||
|
||||
`setInterval` and `setTimeout` callbacks retain references to their closure scope. Failing to clear them causes callbacks to execute indefinitely and prevents garbage collection of referenced objects.
|
||||
|
||||
**Incorrect (intervals never cleared):**
|
||||
|
||||
```typescript
|
||||
class DataPoller {
|
||||
private data: LargeDataset
|
||||
|
||||
start(): void {
|
||||
setInterval(() => {
|
||||
this.data = fetchLatestData()
|
||||
this.updateDashboard()
|
||||
}, 5000)
|
||||
// No reference to interval ID, can't clear it
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// Can't stop the interval - it runs forever
|
||||
// 'this' is retained, DataPoller can't be GC'd
|
||||
}
|
||||
}
|
||||
|
||||
// Each new DataPoller instance creates another interval
|
||||
// Old instances can't be cleaned up
|
||||
```
|
||||
|
||||
**Correct (intervals tracked and cleared):**
|
||||
|
||||
```typescript
|
||||
class DataPoller {
|
||||
private data: LargeDataset
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
start(): void {
|
||||
if (this.intervalId) return // Prevent duplicate intervals
|
||||
|
||||
this.intervalId = setInterval(() => {
|
||||
this.data = fetchLatestData()
|
||||
this.updateDashboard()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId)
|
||||
this.intervalId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**For multiple timers:**
|
||||
|
||||
```typescript
|
||||
class AnimationController {
|
||||
private timers = new Set<ReturnType<typeof setTimeout>>()
|
||||
|
||||
scheduleAnimation(delay: number, callback: () => void): void {
|
||||
const timerId = setTimeout(() => {
|
||||
this.timers.delete(timerId)
|
||||
callback()
|
||||
}, delay)
|
||||
this.timers.add(timerId)
|
||||
}
|
||||
|
||||
cancelAll(): void {
|
||||
for (const timerId of this.timers) {
|
||||
clearTimeout(timerId)
|
||||
}
|
||||
this.timers.clear()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**React hook pattern:**
|
||||
|
||||
```typescript
|
||||
function usePolling(callback: () => void, interval: number): void {
|
||||
useEffect(() => {
|
||||
const id = setInterval(callback, interval)
|
||||
return () => clearInterval(id) // Cleanup on unmount
|
||||
}, [callback, interval])
|
||||
}
|
||||
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay)
|
||||
return () => clearTimeout(timer) // Clear on value change or unmount
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [MDN clearInterval](https://developer.mozilla.org/en-US/docs/Web/API/clearInterval)
|
||||
@@ -1,85 +0,0 @@
|
||||
---
|
||||
title: Use WeakMap for Object Metadata
|
||||
impact: MEDIUM
|
||||
impactDescription: prevents memory leaks, enables automatic cleanup
|
||||
tags: mem, weakmap, metadata, garbage-collection, memory-leaks
|
||||
---
|
||||
|
||||
## Use WeakMap for Object Metadata
|
||||
|
||||
WeakMap allows garbage collection of keys when no other references exist. Use it for associating metadata with objects without preventing their cleanup.
|
||||
|
||||
**Incorrect (Map retains object references):**
|
||||
|
||||
```typescript
|
||||
const userMetadata = new Map<User, UserMetadata>()
|
||||
|
||||
function trackUser(user: User): void {
|
||||
userMetadata.set(user, {
|
||||
lastSeen: Date.now(),
|
||||
pageViews: 0
|
||||
})
|
||||
}
|
||||
|
||||
function removeUser(user: User): void {
|
||||
// Even after user is "removed" from app state,
|
||||
// Map still holds reference, preventing GC
|
||||
userMetadata.delete(user) // Must manually clean up
|
||||
}
|
||||
|
||||
// If delete is forgotten, user objects leak forever
|
||||
```
|
||||
|
||||
**Correct (WeakMap allows GC):**
|
||||
|
||||
```typescript
|
||||
const userMetadata = new WeakMap<User, UserMetadata>()
|
||||
|
||||
function trackUser(user: User): void {
|
||||
userMetadata.set(user, {
|
||||
lastSeen: Date.now(),
|
||||
pageViews: 0
|
||||
})
|
||||
}
|
||||
|
||||
// No cleanup needed - when user object is GC'd,
|
||||
// WeakMap entry is automatically removed
|
||||
function processUsers(users: User[]): void {
|
||||
for (const user of users) {
|
||||
trackUser(user)
|
||||
}
|
||||
// When users array is cleared, all metadata is cleaned up automatically
|
||||
}
|
||||
```
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
```typescript
|
||||
// DOM element metadata
|
||||
const elementState = new WeakMap<HTMLElement, ElementState>()
|
||||
|
||||
function attachState(element: HTMLElement): void {
|
||||
elementState.set(element, { isExpanded: false })
|
||||
// When element is removed from DOM and GC'd, state is cleaned up
|
||||
}
|
||||
|
||||
// Caching computed values
|
||||
const computedCache = new WeakMap<Config, ComputedConfig>()
|
||||
|
||||
function getComputedConfig(config: Config): ComputedConfig {
|
||||
let computed = computedCache.get(config)
|
||||
if (!computed) {
|
||||
computed = expensiveComputation(config)
|
||||
computedCache.set(config, computed)
|
||||
}
|
||||
return computed
|
||||
}
|
||||
```
|
||||
|
||||
**Limitations of WeakMap:**
|
||||
|
||||
- Keys must be objects (not primitives)
|
||||
- Not iterable (no `.keys()`, `.values()`, `.entries()`)
|
||||
- No `.size` property
|
||||
|
||||
Reference: [MDN WeakMap](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap)
|
||||
@@ -1,71 +0,0 @@
|
||||
---
|
||||
title: Avoid Barrel File Imports
|
||||
impact: HIGH
|
||||
impactDescription: 200-800ms import cost, 30-50% larger bundles
|
||||
tags: module, barrel-files, imports, tree-shaking, bundling
|
||||
---
|
||||
|
||||
## Avoid Barrel File Imports
|
||||
|
||||
Barrel files (index.ts re-exports) defeat tree-shaking and force bundlers to load entire module graphs. Import directly from source files to enable proper dead-code elimination.
|
||||
|
||||
**Incorrect (imports entire module tree):**
|
||||
|
||||
```typescript
|
||||
// utils/index.ts (barrel file)
|
||||
export * from './string'
|
||||
export * from './date'
|
||||
export * from './validation'
|
||||
export * from './crypto' // Heavy, rarely used
|
||||
|
||||
// consumer.ts
|
||||
import { formatDate } from '@/utils'
|
||||
// Loads ALL utils modules, including crypto
|
||||
// Bundle includes 50KB of unused code
|
||||
```
|
||||
|
||||
**Correct (direct imports):**
|
||||
|
||||
```typescript
|
||||
// consumer.ts
|
||||
import { formatDate } from '@/utils/date'
|
||||
// Loads only the date module
|
||||
// Bundle includes only what's used
|
||||
```
|
||||
|
||||
**For icon libraries (common barrel offender):**
|
||||
|
||||
```typescript
|
||||
// Incorrect - loads all 1500+ icons
|
||||
import { Check, X } from 'lucide-react'
|
||||
|
||||
// Correct - loads only 2 icons
|
||||
import Check from 'lucide-react/dist/esm/icons/check'
|
||||
import X from 'lucide-react/dist/esm/icons/x'
|
||||
```
|
||||
|
||||
**Alternative (configure bundler optimization):**
|
||||
|
||||
```javascript
|
||||
// next.config.js
|
||||
module.exports = {
|
||||
experimental: {
|
||||
optimizePackageImports: ['lucide-react', '@mui/material', 'lodash']
|
||||
}
|
||||
}
|
||||
|
||||
// vite.config.ts
|
||||
export default {
|
||||
optimizeDeps: {
|
||||
include: ['lucide-react']
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When barrels are acceptable:**
|
||||
|
||||
- Internal modules with few exports (< 10)
|
||||
- Package entry points for library consumers
|
||||
- When bundler is configured to optimize them
|
||||
|
||||
Reference: [Vercel - How we optimized package imports](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)
|
||||
@@ -1,98 +0,0 @@
|
||||
---
|
||||
title: Avoid Circular Dependencies
|
||||
impact: HIGH
|
||||
impactDescription: prevents runtime undefined errors and slow compilation
|
||||
tags: module, circular, dependencies, architecture, compilation
|
||||
---
|
||||
|
||||
## Avoid Circular Dependencies
|
||||
|
||||
Circular dependencies cause undefined values at runtime (due to incomplete module initialization) and slow TypeScript compilation as the checker resolves cycles repeatedly.
|
||||
|
||||
**Incorrect (circular dependency):**
|
||||
|
||||
```typescript
|
||||
// user.ts
|
||||
import { Order } from './order'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
orders: Order[]
|
||||
}
|
||||
|
||||
export function createUser(): User {
|
||||
/* ... */
|
||||
}
|
||||
|
||||
// order.ts
|
||||
import { User } from './user' // Circular!
|
||||
|
||||
export interface Order {
|
||||
id: string
|
||||
user: User
|
||||
}
|
||||
|
||||
export function createOrder(user: User): Order {
|
||||
// 'createUser' might be undefined if order.ts loads first
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (extract shared types):**
|
||||
|
||||
```typescript
|
||||
// types.ts (no dependencies)
|
||||
export interface User {
|
||||
id: string
|
||||
orders: Order[]
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string
|
||||
user: User
|
||||
}
|
||||
|
||||
// user.ts
|
||||
import { User, Order } from './types'
|
||||
|
||||
export function createUser(): User {
|
||||
/* ... */
|
||||
}
|
||||
|
||||
// order.ts
|
||||
import { User, Order } from './types'
|
||||
|
||||
export function createOrder(user: User): Order {
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative (interface segregation):**
|
||||
|
||||
```typescript
|
||||
// user-types.ts
|
||||
export interface UserBase {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
// order.ts
|
||||
import { UserBase } from './user-types'
|
||||
|
||||
export interface Order {
|
||||
id: string
|
||||
user: UserBase // Only needs base interface, not full User
|
||||
}
|
||||
```
|
||||
|
||||
**Detection tools:**
|
||||
|
||||
```bash
|
||||
# Madge - visualize circular dependencies
|
||||
npx madge --circular --extensions ts ./src
|
||||
|
||||
# ESLint plugin
|
||||
npm install eslint-plugin-import
|
||||
# Rule: import/no-cycle
|
||||
```
|
||||
|
||||
Reference: [Node.js Cycles Documentation](https://nodejs.org/api/modules.html#cycles)
|
||||
@@ -1,93 +0,0 @@
|
||||
---
|
||||
title: Control @types Package Inclusion
|
||||
impact: HIGH
|
||||
impactDescription: prevents type conflicts and reduces memory usage
|
||||
tags: module, types, tsconfig, declaration-files, performance
|
||||
---
|
||||
|
||||
## Control @types Package Inclusion
|
||||
|
||||
By default, TypeScript loads all `@types/*` packages from `node_modules`. This causes conflicts between incompatible type versions and wastes memory loading unused declarations.
|
||||
|
||||
**Incorrect (loads all @types automatically):**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# All @types/* packages loaded:
|
||||
# @types/node, @types/react, @types/express, @types/lodash,
|
||||
# @types/jest, @types/mocha (conflict!), @types/jasmine (conflict!)
|
||||
```
|
||||
|
||||
**Correct (explicit types inclusion):**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"types": ["node", "react", "jest"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Only specified @types loaded
|
||||
# No conflicts between test frameworks
|
||||
```
|
||||
|
||||
**For different environments:**
|
||||
|
||||
```json
|
||||
// tsconfig.json (base)
|
||||
{
|
||||
"compilerOptions": {
|
||||
"types": []
|
||||
}
|
||||
}
|
||||
|
||||
// tsconfig.node.json (Node.js scripts)
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
|
||||
// tsconfig.test.json (Jest tests)
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node", "jest"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Using typeRoots for custom declarations:**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"typeRoots": [
|
||||
"./types", // Custom declarations first
|
||||
"./node_modules/@types" // Then @types
|
||||
],
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Prevents type conflicts between similar packages
|
||||
- Reduces memory usage during compilation
|
||||
- Faster IDE responsiveness
|
||||
|
||||
Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#controlling-types-inclusion)
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
title: Use Dynamic Imports for Large Modules
|
||||
impact: HIGH
|
||||
impactDescription: reduces initial bundle by 30-70%
|
||||
tags: module, dynamic-import, code-splitting, lazy-loading, bundling
|
||||
---
|
||||
|
||||
## Use Dynamic Imports for Large Modules
|
||||
|
||||
Dynamic `import()` creates separate chunks that load on demand. Use them for large dependencies, route-specific code, and features that aren't needed immediately.
|
||||
|
||||
**Incorrect (static import, always loaded):**
|
||||
|
||||
```typescript
|
||||
import { PDFGenerator } from 'pdfkit' // 500KB
|
||||
import { ExcelExporter } from 'exceljs' // 800KB
|
||||
import { ChartLibrary } from 'chart.js' // 300KB
|
||||
|
||||
export async function exportReport(format: 'pdf' | 'excel' | 'chart') {
|
||||
if (format === 'pdf') {
|
||||
return new PDFGenerator().generate()
|
||||
}
|
||||
// All 1.6MB loaded even if user never exports
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (dynamic import, loaded on demand):**
|
||||
|
||||
```typescript
|
||||
export async function exportReport(format: 'pdf' | 'excel' | 'chart') {
|
||||
if (format === 'pdf') {
|
||||
const { PDFGenerator } = await import('pdfkit')
|
||||
return new PDFGenerator().generate()
|
||||
}
|
||||
|
||||
if (format === 'excel') {
|
||||
const { ExcelExporter } = await import('exceljs')
|
||||
return new ExcelExporter().export()
|
||||
}
|
||||
|
||||
const { ChartLibrary } = await import('chart.js')
|
||||
return new ChartLibrary().render()
|
||||
}
|
||||
// Only loads the module needed for the specific format
|
||||
```
|
||||
|
||||
**With TypeScript typing:**
|
||||
|
||||
```typescript
|
||||
async function loadPdfGenerator(): Promise<typeof import('pdfkit')> {
|
||||
return import('pdfkit')
|
||||
}
|
||||
|
||||
// Or with type-only import for the interface
|
||||
import type { PDFDocument } from 'pdfkit'
|
||||
|
||||
async function generatePdf(): Promise<PDFDocument> {
|
||||
const { default: PDFDocument } = await import('pdfkit')
|
||||
return new PDFDocument()
|
||||
}
|
||||
```
|
||||
|
||||
**Framework-specific patterns:**
|
||||
|
||||
```typescript
|
||||
// Next.js
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
|
||||
loading: () => <ChartSkeleton />,
|
||||
ssr: false // Skip server-side rendering
|
||||
})
|
||||
|
||||
// React
|
||||
const HeavyChart = React.lazy(() => import('@/components/HeavyChart'))
|
||||
```
|
||||
|
||||
Reference: [MDN Dynamic Import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import)
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
title: Use Type-Only Imports for Types
|
||||
impact: HIGH
|
||||
impactDescription: eliminates runtime imports for type information
|
||||
tags: module, type-imports, tree-shaking, bundling, compilation
|
||||
---
|
||||
|
||||
## Use Type-Only Imports for Types
|
||||
|
||||
Type-only imports (`import type`) are completely erased during compilation, preventing unnecessary runtime module loading. Regular imports of types can force module execution even when only the type is needed.
|
||||
|
||||
**Incorrect (runtime import for type-only usage):**
|
||||
|
||||
```typescript
|
||||
// config.ts
|
||||
import { DatabaseConfig } from './database' // Loads entire database module
|
||||
import { Logger } from './logger' // Loads entire logger module
|
||||
|
||||
interface AppConfig {
|
||||
db: DatabaseConfig
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
// Runtime: database.js and logger.js are both loaded
|
||||
// even though we only use their types
|
||||
```
|
||||
|
||||
**Correct (type-only imports):**
|
||||
|
||||
```typescript
|
||||
// config.ts
|
||||
import type { DatabaseConfig } from './database'
|
||||
import type { Logger } from './logger'
|
||||
|
||||
interface AppConfig {
|
||||
db: DatabaseConfig
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
// Runtime: no modules loaded, types are erased
|
||||
```
|
||||
|
||||
**Mixed imports (types and values):**
|
||||
|
||||
```typescript
|
||||
// Incorrect - unclear what's type vs value
|
||||
import { User, createUser, UserRole } from './user'
|
||||
|
||||
// Correct - explicit separation
|
||||
import { createUser } from './user'
|
||||
import type { User, UserRole } from './user'
|
||||
|
||||
// Or inline type imports (TypeScript 4.5+)
|
||||
import { createUser, type User, type UserRole } from './user'
|
||||
```
|
||||
|
||||
**Enable enforcement:**
|
||||
|
||||
```json
|
||||
// tsconfig.json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"verbatimModuleSyntax": true
|
||||
}
|
||||
}
|
||||
|
||||
// .eslintrc
|
||||
{
|
||||
"rules": {
|
||||
"@typescript-eslint/consistent-type-imports": "error"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Smaller bundles (unused modules not included)
|
||||
- Faster cold starts (fewer modules to parse)
|
||||
- Clearer code intent (types vs runtime values)
|
||||
|
||||
Reference: [TypeScript 3.8 Release Notes](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html#type-only-imports-and-export)
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
title: Avoid Object Spread in Hot Loops
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: reduces object allocations by N×
|
||||
tags: runtime, object-spread, loops, allocation, performance
|
||||
---
|
||||
|
||||
## Avoid Object Spread in Hot Loops
|
||||
|
||||
Object spread (`...`) creates a new object on each use. In loops, this causes N object allocations and copies. Mutate objects directly when creating new instances isn't required.
|
||||
|
||||
**Incorrect (N object allocations):**
|
||||
|
||||
```typescript
|
||||
function enrichOrders(orders: Order[]): EnrichedOrder[] {
|
||||
return orders.map((order) => ({
|
||||
...order, // Creates new object
|
||||
...calculateTotals(order), // Spreads another object
|
||||
processedAt: new Date()
|
||||
}))
|
||||
}
|
||||
// 10,000 orders = 10,000 object spreads = significant GC pressure
|
||||
```
|
||||
|
||||
**Correct (direct assignment):**
|
||||
|
||||
```typescript
|
||||
interface EnrichedOrder extends Order {
|
||||
tax: number
|
||||
shipping: number
|
||||
total: number
|
||||
processedAt: Date
|
||||
}
|
||||
|
||||
function enrichOrders(orders: Order[]): EnrichedOrder[] {
|
||||
return orders.map((order) => {
|
||||
const totals = calculateTotals(order)
|
||||
|
||||
return {
|
||||
id: order.id,
|
||||
customerId: order.customerId,
|
||||
items: order.items,
|
||||
subtotal: order.subtotal,
|
||||
tax: totals.tax,
|
||||
shipping: totals.shipping,
|
||||
total: totals.total,
|
||||
processedAt: new Date()
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** For immutable object creation, explicit property listing is the only spread-free option. This trades verbosity for performance in hot paths. If immutability isn't required, mutating the original object is faster still.
|
||||
|
||||
**For accumulation patterns:**
|
||||
|
||||
```typescript
|
||||
// Incorrect - spreads on every iteration
|
||||
const result = items.reduce(
|
||||
(acc, item) => ({
|
||||
...acc,
|
||||
[item.id]: item.value
|
||||
}),
|
||||
{}
|
||||
)
|
||||
// O(n²) - each spread copies growing object
|
||||
|
||||
// Correct - mutate accumulator
|
||||
const result = items.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.id] = item.value
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, number>
|
||||
)
|
||||
// O(n) - direct property assignment
|
||||
```
|
||||
|
||||
**When spread is acceptable:**
|
||||
|
||||
- Outside hot paths
|
||||
- Small objects (< 10 properties)
|
||||
- When immutability is required for state management
|
||||
- When readability significantly improves
|
||||
|
||||
Reference: [V8 Object Shapes](https://mathiasbynens.be/notes/shapes-ics)
|
||||
@@ -1,67 +0,0 @@
|
||||
---
|
||||
title: Cache Property Access in Loops
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: reduces property lookups by N× in hot paths
|
||||
tags: runtime, loops, caching, property-access, optimization
|
||||
---
|
||||
|
||||
## Cache Property Access in Loops
|
||||
|
||||
Cache deeply nested or polymorphic property access before hot loops. **Note:** Modern V8's inline caches optimize monomorphic access efficiently — this optimization is only meaningful for 10,000+ iterations with deeply nested or polymorphic properties.
|
||||
|
||||
**Incorrect (repeated nested access in hot loop):**
|
||||
|
||||
```typescript
|
||||
function processOrders(orders: Order[], config: AppConfig): ProcessedOrder[] {
|
||||
const results: ProcessedOrder[] = []
|
||||
|
||||
for (const order of orders) {
|
||||
const tax = order.total * config.tax.rate // Nested access each iteration
|
||||
const shipping = config.shipping.rates[order.region] // Nested access again
|
||||
results.push({ ...order, tax, shipping, final: order.total + tax + shipping })
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (cached property access):**
|
||||
|
||||
```typescript
|
||||
function processOrders(orders: Order[], config: AppConfig): ProcessedOrder[] {
|
||||
const results: ProcessedOrder[] = []
|
||||
const taxRate = config.tax.rate
|
||||
const shippingRates = config.shipping.rates
|
||||
|
||||
for (const order of orders) {
|
||||
const tax = order.total * taxRate
|
||||
const shipping = shippingRates[order.region]
|
||||
results.push({ ...order, tax, shipping, final: order.total + tax + shipping })
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
```
|
||||
|
||||
**When V8 handles it automatically (no caching needed):**
|
||||
|
||||
```typescript
|
||||
// Monomorphic — all objects have same shape, V8 ICs optimize this
|
||||
function sumOrders(orders: Order[]): number {
|
||||
let total = 0
|
||||
for (let i = 0; i < orders.length; i++) {
|
||||
// orders.length is fine
|
||||
total += orders[i].total // Same shape every time
|
||||
}
|
||||
return total
|
||||
}
|
||||
```
|
||||
|
||||
**When to skip this optimization:**
|
||||
|
||||
- Arrays under 1,000 items
|
||||
- Monomorphic objects (same shape/class)
|
||||
- Non-hot paths executed infrequently
|
||||
- When readability suffers significantly
|
||||
|
||||
Reference: [V8 Hidden Classes](https://v8.dev/blog/fast-properties)
|
||||
@@ -1,77 +0,0 @@
|
||||
---
|
||||
title: Prefer Native Array Methods Over Lodash
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: eliminates library overhead, enables tree-shaking
|
||||
tags: runtime, arrays, lodash, native-methods, bundling
|
||||
---
|
||||
|
||||
## Prefer Native Array Methods Over Lodash
|
||||
|
||||
Modern JavaScript includes most common array operations. Native methods are faster (no function call overhead) and don't add bundle weight. Use native methods when they provide equivalent functionality.
|
||||
|
||||
**Incorrect (lodash for native operations):**
|
||||
|
||||
```typescript
|
||||
import _ from 'lodash' // Imports entire library
|
||||
|
||||
const activeUsers = _.filter(users, (u) => u.isActive)
|
||||
const userNames = _.map(activeUsers, (u) => u.name)
|
||||
const firstAdmin = _.find(users, (u) => u.role === 'admin')
|
||||
const hasAdmin = _.some(users, (u) => u.role === 'admin')
|
||||
const allActive = _.every(users, (u) => u.isActive)
|
||||
const userIds = _.uniq(users.map((u) => u.id))
|
||||
```
|
||||
|
||||
**Correct (native methods):**
|
||||
|
||||
```typescript
|
||||
const activeUsers = users.filter((u) => u.isActive)
|
||||
const userNames = activeUsers.map((u) => u.name)
|
||||
const firstAdmin = users.find((u) => u.role === 'admin')
|
||||
const hasAdmin = users.some((u) => u.role === 'admin')
|
||||
const allActive = users.every((u) => u.isActive)
|
||||
const userIds = [...new Set(users.map((u) => u.id))]
|
||||
```
|
||||
|
||||
**Native replacements for common Lodash functions:**
|
||||
|
||||
```typescript
|
||||
// _.flatten / _.flattenDeep
|
||||
const flat = nestedArrays.flat(Infinity)
|
||||
|
||||
// _.chunk (still useful from lodash)
|
||||
function chunk<T>(array: T[], size: number): T[][] {
|
||||
return Array.from({ length: Math.ceil(array.length / size) }, (_, i) =>
|
||||
array.slice(i * size, i * size + size)
|
||||
)
|
||||
}
|
||||
|
||||
// _.groupBy
|
||||
function groupBy<T>(array: T[], key: keyof T): Record<string, T[]> {
|
||||
return array.reduce(
|
||||
(groups, item) => {
|
||||
const group = String(item[key])
|
||||
groups[group] = groups[group] ?? []
|
||||
groups[group].push(item)
|
||||
return groups
|
||||
},
|
||||
{} as Record<string, T[]>
|
||||
)
|
||||
}
|
||||
|
||||
// Object.groupBy (ES2024)
|
||||
const grouped = Object.groupBy(users, (user) => user.role)
|
||||
|
||||
// _.pick / _.omit
|
||||
const { password, ...userWithoutPassword } = user // omit
|
||||
const { id, name } = user // pick
|
||||
```
|
||||
|
||||
**When Lodash is still valuable:**
|
||||
|
||||
- `_.debounce`, `_.throttle` - complex timing logic
|
||||
- `_.cloneDeep` - deep object cloning
|
||||
- `_.merge` - deep object merging
|
||||
- `_.get` with default values (but optional chaining often suffices)
|
||||
|
||||
Reference: [You Don't Need Lodash](https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore)
|
||||
@@ -1,89 +0,0 @@
|
||||
---
|
||||
title: Use for-of for Simple Iteration
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: reduces iteration boilerplate by 30-50%
|
||||
tags: runtime, loops, iteration, for-of, readability
|
||||
---
|
||||
|
||||
## Use for-of for Simple Iteration
|
||||
|
||||
`for-of` provides clean syntax for array iteration with performance comparable to traditional `for` loops. Use it when you don't need the index and aren't modifying the array.
|
||||
|
||||
**Incorrect (index-based when index isn't needed):**
|
||||
|
||||
```typescript
|
||||
function calculateTotal(orders: Order[]): number {
|
||||
let total = 0
|
||||
for (let i = 0; i < orders.length; i++) {
|
||||
total += orders[i].amount
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
function processUsers(users: User[]): void {
|
||||
for (let i = 0; i < users.length; i++) {
|
||||
sendNotification(users[i])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (for-of for clean iteration):**
|
||||
|
||||
```typescript
|
||||
function calculateTotal(orders: Order[]): number {
|
||||
let total = 0
|
||||
for (const order of orders) {
|
||||
total += order.amount
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
function processUsers(users: User[]): void {
|
||||
for (const user of users) {
|
||||
sendNotification(user)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use each pattern:**
|
||||
|
||||
```typescript
|
||||
// for-of: when you only need values
|
||||
for (const item of items) {
|
||||
process(item)
|
||||
}
|
||||
|
||||
// forEach: when you want functional style (but can't break/return)
|
||||
items.forEach((item) => process(item))
|
||||
|
||||
// for-in: only for object keys (never for arrays)
|
||||
for (const key in config) {
|
||||
console.log(key, config[key])
|
||||
}
|
||||
|
||||
// Traditional for: when you need index, or need to modify loop
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].id === targetId) {
|
||||
items[i] = updatedItem // Modifying array
|
||||
break // Early exit
|
||||
}
|
||||
}
|
||||
|
||||
// entries(): when you need both index and value
|
||||
for (const [index, item] of items.entries()) {
|
||||
console.log(`${index}: ${item.name}`)
|
||||
}
|
||||
```
|
||||
|
||||
**Avoid for-in for arrays:**
|
||||
|
||||
```typescript
|
||||
// NEVER do this
|
||||
for (const index in items) {
|
||||
// index is a string, not number
|
||||
// Iterates inherited properties
|
||||
// Wrong order not guaranteed
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [MDN for...of](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...of)
|
||||
@@ -1,67 +0,0 @@
|
||||
---
|
||||
title: Use Set/Map for O(1) Lookups
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: O(n) to O(1) per lookup
|
||||
tags: runtime, set, map, lookup, performance
|
||||
---
|
||||
|
||||
## Use Set/Map for O(1) Lookups
|
||||
|
||||
Array methods like `.includes()` and `.find()` are O(n) operations. For frequent lookups, convert arrays to Set or Map for O(1) access.
|
||||
|
||||
**Incorrect (O(n) per lookup):**
|
||||
|
||||
```typescript
|
||||
const allowedRoles = ['admin', 'editor', 'viewer', 'moderator']
|
||||
|
||||
function hasPermission(userRole: string): boolean {
|
||||
return allowedRoles.includes(userRole) // O(n) every call
|
||||
}
|
||||
|
||||
// In a loop, this becomes O(n × m)
|
||||
function filterAuthorizedUsers(users: User[]): User[] {
|
||||
return users.filter((user) => allowedRoles.includes(user.role))
|
||||
// 1000 users × 4 roles = 4000 comparisons
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (O(1) per lookup):**
|
||||
|
||||
```typescript
|
||||
const allowedRoles = new Set(['admin', 'editor', 'viewer', 'moderator'])
|
||||
|
||||
function hasPermission(userRole: string): boolean {
|
||||
return allowedRoles.has(userRole) // O(1) every call
|
||||
}
|
||||
|
||||
function filterAuthorizedUsers(users: User[]): User[] {
|
||||
return users.filter((user) => allowedRoles.has(user.role))
|
||||
// 1000 users × O(1) = 1000 operations
|
||||
}
|
||||
```
|
||||
|
||||
**For object lookups by key:**
|
||||
|
||||
```typescript
|
||||
// Incorrect - O(n) search
|
||||
const users: User[] = [
|
||||
/* ... */
|
||||
]
|
||||
function findUserById(id: string): User | undefined {
|
||||
return users.find((u) => u.id === id) // Scans entire array
|
||||
}
|
||||
|
||||
// Correct - O(1) lookup
|
||||
const userById = new Map<string, User>(users.map((u) => [u.id, u]))
|
||||
function findUserById(id: string): User | undefined {
|
||||
return userById.get(id)
|
||||
}
|
||||
```
|
||||
|
||||
**When to stick with arrays:**
|
||||
|
||||
- Small collections (< 10 items)
|
||||
- One-time lookups where conversion cost exceeds benefit
|
||||
- When you need array methods like `.map()`, `.filter()`, `.slice()`
|
||||
|
||||
Reference: [MDN Set](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set)
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
title: Use Modern String Methods
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: 2-5× faster than regex for simple patterns
|
||||
tags: runtime, strings, methods, performance, readability
|
||||
---
|
||||
|
||||
## Use Modern String Methods
|
||||
|
||||
Modern string methods like `startsWith()`, `endsWith()`, `includes()`, and `padStart()` are clearer and often faster than regex or manual substring operations.
|
||||
|
||||
**Incorrect (regex or substring for simple checks):**
|
||||
|
||||
```typescript
|
||||
function isImageFile(filename: string): boolean {
|
||||
return /\.(jpg|png|gif)$/.test(filename)
|
||||
}
|
||||
|
||||
function hasHttpPrefix(url: string): boolean {
|
||||
return url.substring(0, 7) === 'http://' || url.substring(0, 8) === 'https://'
|
||||
}
|
||||
|
||||
function containsSearchTerm(text: string, term: string): boolean {
|
||||
return text.indexOf(term) !== -1
|
||||
}
|
||||
|
||||
function formatOrderId(id: number): string {
|
||||
return ('000000' + id).slice(-6) // Pad to 6 digits
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (modern string methods):**
|
||||
|
||||
```typescript
|
||||
function isImageFile(filename: string): boolean {
|
||||
return filename.endsWith('.jpg') || filename.endsWith('.png') || filename.endsWith('.gif')
|
||||
}
|
||||
|
||||
function hasHttpPrefix(url: string): boolean {
|
||||
return url.startsWith('http://') || url.startsWith('https://')
|
||||
}
|
||||
|
||||
function containsSearchTerm(text: string, term: string): boolean {
|
||||
return text.includes(term)
|
||||
}
|
||||
|
||||
function formatOrderId(id: number): string {
|
||||
return String(id).padStart(6, '0')
|
||||
}
|
||||
```
|
||||
|
||||
**Additional useful methods:**
|
||||
|
||||
```typescript
|
||||
// replaceAll (no global regex needed)
|
||||
const sanitized = input.replaceAll('<', '<').replaceAll('>', '>')
|
||||
|
||||
// at() for negative indexing
|
||||
const lastChar = filename.at(-1) // Last character
|
||||
const extension = filename.split('.').at(-1) // Last segment
|
||||
|
||||
// trimStart/trimEnd for directional trimming
|
||||
const trimmedLeft = ' text '.trimStart() // 'text '
|
||||
const trimmedRight = ' text '.trimEnd() // ' text'
|
||||
|
||||
// repeat for string multiplication
|
||||
const separator = '-'.repeat(40)
|
||||
const indent = ' '.repeat(depth)
|
||||
```
|
||||
|
||||
**When regex is still needed:**
|
||||
|
||||
- Complex pattern matching
|
||||
- Capture groups
|
||||
- Case-insensitive matching (`/pattern/i`)
|
||||
- Multiple conditions in one check
|
||||
|
||||
Reference: [MDN String Methods](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)
|
||||
@@ -1,93 +0,0 @@
|
||||
---
|
||||
title: Use Assertion Functions for Validation
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: reduces validation boilerplate by 50-70%
|
||||
tags: safety, assertion-functions, asserts, validation, narrowing
|
||||
---
|
||||
|
||||
## Use Assertion Functions for Validation
|
||||
|
||||
Assertion functions (`asserts` return type) tell TypeScript that if the function returns, the condition is true. This narrows types in the calling scope without explicit if-checks.
|
||||
|
||||
**Incorrect (repeated if-throw pattern):**
|
||||
|
||||
```typescript
|
||||
function processOrder(order: Order | null): void {
|
||||
if (!order) {
|
||||
throw new Error('Order is required')
|
||||
}
|
||||
if (order.status !== 'pending') {
|
||||
throw new Error('Order must be pending')
|
||||
}
|
||||
if (!order.items.length) {
|
||||
throw new Error('Order must have items')
|
||||
}
|
||||
|
||||
// Finally can use order safely
|
||||
submitOrder(order)
|
||||
}
|
||||
|
||||
// Same checks repeated in every function that needs a valid order
|
||||
function shipOrder(order: Order | null): void {
|
||||
if (!order) throw new Error('Order is required')
|
||||
if (order.status !== 'pending') throw new Error('Order must be pending')
|
||||
// ...duplicate validation
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (assertion function):**
|
||||
|
||||
```typescript
|
||||
interface ValidOrder extends Order {
|
||||
status: 'pending'
|
||||
items: [OrderItem, ...OrderItem[]] // Non-empty array
|
||||
}
|
||||
|
||||
function assertValidOrder(order: Order | null): asserts order is ValidOrder {
|
||||
if (!order) {
|
||||
throw new Error('Order is required')
|
||||
}
|
||||
if (order.status !== 'pending') {
|
||||
throw new Error('Order must be pending')
|
||||
}
|
||||
if (!order.items.length) {
|
||||
throw new Error('Order must have items')
|
||||
}
|
||||
}
|
||||
|
||||
function processOrder(order: Order | null): void {
|
||||
assertValidOrder(order)
|
||||
// order is now typed as ValidOrder
|
||||
submitOrder(order) // Type-safe
|
||||
}
|
||||
|
||||
function shipOrder(order: Order | null): void {
|
||||
assertValidOrder(order)
|
||||
// Reuses validation, order is ValidOrder
|
||||
ship(order)
|
||||
}
|
||||
```
|
||||
|
||||
**For generic assertions:**
|
||||
|
||||
```typescript
|
||||
function assertDefined<T>(value: T | null | undefined, name: string): asserts value is T {
|
||||
if (value === null || value === undefined) {
|
||||
throw new Error(`${name} must be defined`)
|
||||
}
|
||||
}
|
||||
|
||||
function processUser(user: User | null): void {
|
||||
assertDefined(user, 'user')
|
||||
// user is now User, not User | null
|
||||
console.log(user.email)
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Centralizes validation logic
|
||||
- Automatic type narrowing after assertion
|
||||
- Clearer intent than if-throw patterns
|
||||
|
||||
Reference: [TypeScript 3.7 Assertion Functions](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions)
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
title: Use const Assertions for Literal Types
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: preserves literal types, enables better inference
|
||||
tags: safety, const-assertion, literals, inference, readonly
|
||||
---
|
||||
|
||||
## Use const Assertions for Literal Types
|
||||
|
||||
The `as const` assertion preserves literal types and makes arrays/objects readonly. This enables precise type inference and prevents accidental mutations.
|
||||
|
||||
**Incorrect (widened types):**
|
||||
|
||||
```typescript
|
||||
const config = {
|
||||
apiUrl: 'https://api.example.com',
|
||||
retries: 3,
|
||||
methods: ['GET', 'POST']
|
||||
}
|
||||
// Type: { apiUrl: string; retries: number; methods: string[] }
|
||||
|
||||
function makeRequest(method: 'GET' | 'POST'): void {}
|
||||
|
||||
makeRequest(config.methods[0])
|
||||
// Error: Argument of type 'string' is not assignable to 'GET' | 'POST'
|
||||
|
||||
const STATUS = {
|
||||
PENDING: 'pending',
|
||||
ACTIVE: 'active'
|
||||
}
|
||||
// Type: { PENDING: string; ACTIVE: string }
|
||||
```
|
||||
|
||||
**Correct (const assertion preserves literals):**
|
||||
|
||||
```typescript
|
||||
const config = {
|
||||
apiUrl: 'https://api.example.com',
|
||||
retries: 3,
|
||||
methods: ['GET', 'POST']
|
||||
} as const
|
||||
// Type: { readonly apiUrl: 'https://api.example.com'; readonly retries: 3; readonly methods: readonly ['GET', 'POST'] }
|
||||
|
||||
function makeRequest(method: 'GET' | 'POST'): void {}
|
||||
|
||||
makeRequest(config.methods[0]) // Works: 'GET' is assignable to 'GET' | 'POST'
|
||||
|
||||
const STATUS = {
|
||||
PENDING: 'pending',
|
||||
ACTIVE: 'active'
|
||||
} as const
|
||||
// Type: { readonly PENDING: 'pending'; readonly ACTIVE: 'active' }
|
||||
|
||||
type StatusType = (typeof STATUS)[keyof typeof STATUS] // 'pending' | 'active'
|
||||
```
|
||||
|
||||
**For function parameters:**
|
||||
|
||||
```typescript
|
||||
// Incorrect - tuple becomes array
|
||||
function setCoordinates(coords: [number, number]): void {}
|
||||
setCoordinates([10, 20]) // Error: number[] not assignable to [number, number]
|
||||
|
||||
// Correct - const preserves tuple
|
||||
setCoordinates([10, 20] as const) // Works
|
||||
|
||||
// Or inline
|
||||
function setCoordinates(coords: readonly [number, number]): void {}
|
||||
```
|
||||
|
||||
**When to use const assertions:**
|
||||
|
||||
- Configuration objects that shouldn't change
|
||||
- Enum-like objects with string values
|
||||
- Array/tuple literals passed to functions expecting specific types
|
||||
- Creating type-safe lookup tables
|
||||
|
||||
Reference: [TypeScript 3.4 Const Assertions](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html#const-assertions)
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
title: Use Exhaustive Checks for Union Types
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: prevents 100% of missing case errors at compile time
|
||||
tags: safety, exhaustive, never, discriminated-unions, switch
|
||||
---
|
||||
|
||||
## Use Exhaustive Checks for Union Types
|
||||
|
||||
Exhaustive checks ensure all union members are handled. When a new member is added, TypeScript errors on unhandled cases rather than falling through silently at runtime.
|
||||
|
||||
**Incorrect (missing case compiles but fails at runtime):**
|
||||
|
||||
```typescript
|
||||
type OrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered'
|
||||
|
||||
function getStatusMessage(status: OrderStatus): string {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'Order received'
|
||||
case 'processing':
|
||||
return 'Preparing your order'
|
||||
case 'shipped':
|
||||
return 'On the way'
|
||||
// 'delivered' case missing - no compile error
|
||||
// Returns undefined at runtime
|
||||
}
|
||||
}
|
||||
|
||||
// Later, someone adds 'cancelled' to OrderStatus
|
||||
// This function silently returns undefined for 'cancelled' and 'delivered'
|
||||
```
|
||||
|
||||
**Correct (exhaustive check with never):**
|
||||
|
||||
```typescript
|
||||
type OrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered'
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unhandled value: ${value}`)
|
||||
}
|
||||
|
||||
function getStatusMessage(status: OrderStatus): string {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'Order received'
|
||||
case 'processing':
|
||||
return 'Preparing your order'
|
||||
case 'shipped':
|
||||
return 'On the way'
|
||||
case 'delivered':
|
||||
return 'Order complete'
|
||||
default:
|
||||
return assertNever(status) // Compile error if case missed
|
||||
}
|
||||
}
|
||||
|
||||
// Adding 'cancelled' to OrderStatus now causes compile error:
|
||||
// Argument of type 'string' is not assignable to parameter of type 'never'
|
||||
```
|
||||
|
||||
**For object mapping (alternative pattern):**
|
||||
|
||||
```typescript
|
||||
const statusMessages: Record<OrderStatus, string> = {
|
||||
pending: 'Order received',
|
||||
processing: 'Preparing your order',
|
||||
shipped: 'On the way',
|
||||
delivered: 'Order complete'
|
||||
// Missing key causes: Property 'cancelled' is missing in type
|
||||
}
|
||||
|
||||
function getStatusMessage(status: OrderStatus): string {
|
||||
return statusMessages[status]
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Compile-time error when union expands
|
||||
- Self-documenting: all cases explicitly handled
|
||||
- Runtime safety via assertNever fallback
|
||||
|
||||
Reference: [TypeScript Handbook - Exhaustiveness Checking](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#exhaustiveness-checking)
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
title: Enable noUncheckedIndexedAccess
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: prevents 100% of unchecked index access errors at compile time
|
||||
tags: safety, noUncheckedIndexedAccess, strict, arrays, undefined
|
||||
---
|
||||
|
||||
## Enable noUncheckedIndexedAccess
|
||||
|
||||
With `noUncheckedIndexedAccess` enabled, TypeScript adds `undefined` to the type of array elements and index signature properties. This catches one of the most common sources of runtime errors — accessing elements that may not exist.
|
||||
|
||||
**Incorrect (without noUncheckedIndexedAccess):**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
const users = ['Alice', 'Bob', 'Charlie']
|
||||
const first = users[0] // Type: string (lies — could be undefined)
|
||||
console.log(first.toUpperCase()) // No error, but crashes if array is empty
|
||||
|
||||
const scores: Record<string, number> = { math: 95 }
|
||||
const science = scores['science'] // Type: number (lies — key doesn't exist)
|
||||
console.log(science.toFixed(2)) // No error, but crashes at runtime
|
||||
```
|
||||
|
||||
**Correct (with noUncheckedIndexedAccess):**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
const users = ['Alice', 'Bob', 'Charlie']
|
||||
const first = users[0] // Type: string | undefined
|
||||
console.log(first.toUpperCase()) // Error: 'first' is possibly undefined
|
||||
|
||||
// Handle the undefined case
|
||||
if (first) {
|
||||
console.log(first.toUpperCase()) // OK after narrowing
|
||||
}
|
||||
|
||||
const scores: Record<string, number> = { math: 95 }
|
||||
const science = scores['science'] // Type: number | undefined
|
||||
if (science !== undefined) {
|
||||
console.log(science.toFixed(2)) // OK after narrowing
|
||||
}
|
||||
```
|
||||
|
||||
**Common patterns with noUncheckedIndexedAccess:**
|
||||
|
||||
```typescript
|
||||
// Array destructuring — first element is T | undefined
|
||||
const [head, ...rest] = items
|
||||
if (head) {
|
||||
processItem(head)
|
||||
}
|
||||
|
||||
// Use non-null assertion only when you've validated
|
||||
function getRequired(items: string[], index: number): string {
|
||||
if (index < 0 || index >= items.length) {
|
||||
throw new RangeError(`Index ${index} out of bounds`)
|
||||
}
|
||||
return items[index]! // Safe — bounds checked above
|
||||
}
|
||||
|
||||
// Array.at() returns T | undefined regardless of this flag
|
||||
const last = items.at(-1) // Already T | undefined
|
||||
```
|
||||
|
||||
**When to disable:**
|
||||
|
||||
- Legacy codebases with heavy array indexing (migration cost too high)
|
||||
- Performance-critical inner loops where the narrowing pattern adds overhead
|
||||
|
||||
Reference: [TypeScript tsconfig - noUncheckedIndexedAccess](https://www.typescriptlang.org/tsconfig/noUncheckedIndexedAccess.html)
|
||||
@@ -1,77 +0,0 @@
|
||||
---
|
||||
title: Prefer unknown Over any
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: forces type narrowing, prevents runtime errors
|
||||
tags: safety, unknown, any, type-narrowing, type-safety
|
||||
---
|
||||
|
||||
## Prefer unknown Over any
|
||||
|
||||
The `any` type disables all type checking, allowing unsafe operations to pass silently. Use `unknown` to require explicit type narrowing before operations.
|
||||
|
||||
**Incorrect (any bypasses all checks):**
|
||||
|
||||
```typescript
|
||||
function processApiResponse(data: any): string {
|
||||
return data.user.name.toUpperCase()
|
||||
// No error even if data is null, has no user, or name isn't a string
|
||||
// Runtime: TypeError: Cannot read property 'name' of undefined
|
||||
}
|
||||
|
||||
async function fetchData(): Promise<any> {
|
||||
const response = await fetch('/api/data')
|
||||
return response.json() // Returns Promise<any>, loses all type info
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (unknown requires narrowing):**
|
||||
|
||||
```typescript
|
||||
interface ApiResponse {
|
||||
user: {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
function isApiResponse(data: unknown): data is ApiResponse {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'user' in data &&
|
||||
typeof (data as ApiResponse).user?.name === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function processApiResponse(data: unknown): string {
|
||||
if (!isApiResponse(data)) {
|
||||
throw new Error('Invalid API response')
|
||||
}
|
||||
return data.user.name.toUpperCase() // Type-safe access
|
||||
}
|
||||
```
|
||||
|
||||
**For JSON parsing:**
|
||||
|
||||
```typescript
|
||||
// Incorrect
|
||||
const config = JSON.parse(configString) as AppConfig // Unsafe assertion
|
||||
|
||||
// Correct
|
||||
function parseConfig(configString: string): AppConfig {
|
||||
const parsed: unknown = JSON.parse(configString)
|
||||
|
||||
if (!isValidConfig(parsed)) {
|
||||
throw new Error('Invalid config format')
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
```
|
||||
|
||||
**When any is acceptable:**
|
||||
|
||||
- Migrating JavaScript to TypeScript incrementally
|
||||
- Third-party library workarounds (with `// @ts-expect-error`)
|
||||
- Truly dynamic code where type is unknowable
|
||||
|
||||
Reference: [TypeScript Handbook - Unknown](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#the-unknown-type)
|
||||
@@ -1,72 +0,0 @@
|
||||
---
|
||||
title: Enable strictNullChecks
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: prevents null/undefined runtime errors
|
||||
tags: safety, strictNullChecks, null, undefined, strict
|
||||
---
|
||||
|
||||
## Enable strictNullChecks
|
||||
|
||||
With `strictNullChecks`, TypeScript distinguishes between `T`, `T | null`, and `T | undefined`. This catches null pointer exceptions at compile time instead of runtime.
|
||||
|
||||
**Incorrect (strictNullChecks disabled):**
|
||||
|
||||
```typescript
|
||||
// tsconfig.json: { "strictNullChecks": false }
|
||||
|
||||
function getUser(id: string): User {
|
||||
return userMap.get(id) // Returns User | undefined, but typed as User
|
||||
}
|
||||
|
||||
const user = getUser('123')
|
||||
console.log(user.email) // No error, but crashes if user is undefined
|
||||
```
|
||||
|
||||
**Correct (strictNullChecks enabled):**
|
||||
|
||||
```typescript
|
||||
// tsconfig.json: { "strict": true } (includes strictNullChecks)
|
||||
|
||||
function getUser(id: string): User | undefined {
|
||||
return userMap.get(id) // Correctly typed as User | undefined
|
||||
}
|
||||
|
||||
const user = getUser('123')
|
||||
console.log(user.email) // Error: 'user' is possibly 'undefined'
|
||||
|
||||
// Must handle the undefined case
|
||||
if (user) {
|
||||
console.log(user.email) // Type narrowed to User
|
||||
}
|
||||
|
||||
// Or use optional chaining
|
||||
console.log(user?.email) // string | undefined
|
||||
|
||||
// Or assert when you're certain
|
||||
const confirmedUser = getUser('123')! // Non-null assertion (use sparingly)
|
||||
```
|
||||
|
||||
**Common patterns with strictNullChecks:**
|
||||
|
||||
```typescript
|
||||
// Default values
|
||||
function greet(name: string | undefined): string {
|
||||
return `Hello, ${name ?? 'Guest'}`
|
||||
}
|
||||
|
||||
// Guard clauses
|
||||
function processOrder(order: Order | null): void {
|
||||
if (!order) {
|
||||
throw new Error('Order is required')
|
||||
}
|
||||
// order is narrowed to Order
|
||||
ship(order)
|
||||
}
|
||||
|
||||
// Optional chaining with nullish coalescing
|
||||
const street = user?.address?.street ?? 'Unknown'
|
||||
```
|
||||
|
||||
**Note:** Always enable `strict: true` which includes `strictNullChecks` along with other safety checks.
|
||||
|
||||
Reference: [TypeScript Handbook - Strict Null Checks](https://www.typescriptlang.org/tsconfig#strictNullChecks)
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
title: Use Type Guards for Runtime Type Checking
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: eliminates type assertions, catches errors at boundaries
|
||||
tags: safety, type-guards, narrowing, predicates, validation
|
||||
---
|
||||
|
||||
## Use Type Guards for Runtime Type Checking
|
||||
|
||||
Type guards provide runtime validation that TypeScript can use for static narrowing. They replace unsafe type assertions with checked operations.
|
||||
|
||||
**Incorrect (type assertions without validation):**
|
||||
|
||||
```typescript
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
role: 'admin' | 'user'
|
||||
}
|
||||
|
||||
function handleUserEvent(event: MessageEvent): void {
|
||||
const user = event.data as User // Unsafe assertion
|
||||
sendEmail(user.email) // Crashes if data isn't actually a User
|
||||
}
|
||||
|
||||
function processResponse(data: unknown): User[] {
|
||||
return data as User[] // No runtime check
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (type guard with validation):**
|
||||
|
||||
```typescript
|
||||
function isUser(value: unknown): value is User {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as User).id === 'string' &&
|
||||
typeof (value as User).email === 'string' &&
|
||||
['admin', 'user'].includes((value as User).role)
|
||||
)
|
||||
}
|
||||
|
||||
function handleUserEvent(event: MessageEvent): void {
|
||||
if (!isUser(event.data)) {
|
||||
console.error('Invalid user data received')
|
||||
return
|
||||
}
|
||||
sendEmail(event.data.email) // Type-safe: event.data is User
|
||||
}
|
||||
|
||||
function processResponse(data: unknown): User[] {
|
||||
if (!Array.isArray(data)) return []
|
||||
return data.filter(isUser)
|
||||
}
|
||||
```
|
||||
|
||||
**For discriminated unions:**
|
||||
|
||||
```typescript
|
||||
interface SuccessResult {
|
||||
status: 'success'
|
||||
data: User
|
||||
}
|
||||
|
||||
interface ErrorResult {
|
||||
status: 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
type ApiResult = SuccessResult | ErrorResult
|
||||
|
||||
function isSuccess(result: ApiResult): result is SuccessResult {
|
||||
return result.status === 'success'
|
||||
}
|
||||
|
||||
function handleResult(result: ApiResult): void {
|
||||
if (isSuccess(result)) {
|
||||
console.log(result.data.email) // Type narrowed to SuccessResult
|
||||
} else {
|
||||
console.error(result.message) // Type narrowed to ErrorResult
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [TypeScript Handbook - Narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html)
|
||||
@@ -1,66 +0,0 @@
|
||||
---
|
||||
title: Enable Incremental Compilation
|
||||
impact: CRITICAL
|
||||
impactDescription: 50-90% faster rebuilds
|
||||
tags: tscfg, incremental, tsconfig, compilation, caching
|
||||
---
|
||||
|
||||
## Enable Incremental Compilation
|
||||
|
||||
Incremental compilation caches project graph information between builds in a `.tsbuildinfo` file. Subsequent compilations only recheck changed files and their dependents.
|
||||
|
||||
**Incorrect (full rebuild every time):**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
tsc # 15 seconds every build
|
||||
```
|
||||
|
||||
**Correct (incremental builds):**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"strict": true,
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": "./dist/.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
tsc # 15s first build, 1-3s subsequent builds
|
||||
```
|
||||
|
||||
**For monorepos (composite projects):**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"references": [{ "path": "../shared" }, { "path": "../utils" }]
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** The `composite` flag implies `incremental: true` and requires `declaration: true`.
|
||||
|
||||
**When to disable incremental:**
|
||||
|
||||
- CI environments where cache isn't preserved between runs
|
||||
- One-off type-checking scripts
|
||||
|
||||
Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#incremental-project-emit)
|
||||
@@ -1,88 +0,0 @@
|
||||
---
|
||||
title: Use erasableSyntaxOnly for Node.js Native TypeScript
|
||||
impact: HIGH
|
||||
impactDescription: prevents 100% of Node.js type-stripping runtime errors
|
||||
tags: tscfg, erasableSyntaxOnly, node, type-stripping, enums
|
||||
---
|
||||
|
||||
## Use erasableSyntaxOnly for Node.js Native TypeScript
|
||||
|
||||
The `erasableSyntaxOnly` flag (TypeScript 5.8+) ensures your code only uses TypeScript syntax that can be removed by erasing type annotations — no code generation required. This is mandatory for Node.js `--experimental-strip-types` which strips types but cannot transform enums, namespaces, or parameter properties.
|
||||
|
||||
**Incorrect (non-erasable syntax fails with Node.js type-stripping):**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"erasableSyntaxOnly": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Error: non-erasable syntax
|
||||
export enum OrderStatus {
|
||||
Pending = 'pending',
|
||||
Shipped = 'shipped',
|
||||
Delivered = 'delivered'
|
||||
}
|
||||
|
||||
// Error: non-erasable syntax
|
||||
namespace Validation {
|
||||
export function isValid(input: string): boolean {
|
||||
return input.length > 0
|
||||
}
|
||||
}
|
||||
|
||||
// Error: non-erasable parameter property
|
||||
class UserService {
|
||||
constructor(private readonly repository: UserRepository) {}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (erasable alternatives):**
|
||||
|
||||
```typescript
|
||||
// Union type instead of enum
|
||||
export type OrderStatus = 'pending' | 'shipped' | 'delivered'
|
||||
|
||||
// Object constant for runtime values
|
||||
export const OrderStatus = {
|
||||
Pending: 'pending',
|
||||
Shipped: 'shipped',
|
||||
Delivered: 'delivered'
|
||||
} as const satisfies Record<string, OrderStatus>
|
||||
|
||||
// Module-level functions instead of namespace
|
||||
export function isValid(input: string): boolean {
|
||||
return input.length > 0
|
||||
}
|
||||
|
||||
// Explicit property assignment instead of parameter property
|
||||
class UserService {
|
||||
readonly repository: UserRepository
|
||||
constructor(repository: UserRepository) {
|
||||
this.repository = repository
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Recommended configuration for Node.js native TS:**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"erasableSyntaxOnly": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When NOT to use this flag:**
|
||||
|
||||
- Projects using a bundler (esbuild, swc, Vite) that supports enum transformation
|
||||
- Libraries that need to support both bundled and unbundled consumers
|
||||
- Codebases with extensive enum usage where migration cost is high
|
||||
|
||||
Reference: [TypeScript 5.8 - erasableSyntaxOnly](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-8.html)
|
||||
@@ -1,80 +0,0 @@
|
||||
---
|
||||
title: Configure Include and Exclude Properly
|
||||
impact: CRITICAL
|
||||
impactDescription: prevents scanning thousands of unnecessary files
|
||||
tags: tscfg, include, exclude, tsconfig, file-discovery
|
||||
---
|
||||
|
||||
## Configure Include and Exclude Properly
|
||||
|
||||
TypeScript walks through all included directories to discover files. Overly broad `include` patterns or missing `exclude` patterns force the compiler to scan irrelevant directories, significantly slowing startup.
|
||||
|
||||
**Incorrect (scans entire project tree):**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["**/*"]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Scans node_modules, dist, coverage, .git...
|
||||
# Discovery time: 5+ seconds on large projects
|
||||
```
|
||||
|
||||
**Correct (targeted include with explicit exclude):**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "coverage", "**/*.test.ts", "**/*.spec.ts", "**/__tests__/**"]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Only scans src/ directory
|
||||
# Discovery time: <1 second
|
||||
```
|
||||
|
||||
**For separate test configuration:**
|
||||
|
||||
```json
|
||||
// tsconfig.json (production)
|
||||
{
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["**/*.test.ts"]
|
||||
}
|
||||
|
||||
// tsconfig.test.json
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src/**/*", "tests/**/*"]
|
||||
}
|
||||
```
|
||||
|
||||
**Diagnostic commands:**
|
||||
|
||||
```bash
|
||||
# List all files TypeScript will compile
|
||||
tsc --listFiles
|
||||
|
||||
# Explain why each file was included
|
||||
tsc --explainFiles
|
||||
```
|
||||
|
||||
**Common files to exclude:**
|
||||
|
||||
- `node_modules` (always)
|
||||
- Build output directories (`dist`, `build`, `out`)
|
||||
- Test files for production builds
|
||||
- Generated files (`.generated.ts`)
|
||||
- Coverage reports (`coverage`)
|
||||
|
||||
Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#configuring-tsconfigjson-or-jsconfigjson)
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
title: Use isolatedModules for Single-File Transpilation
|
||||
impact: CRITICAL
|
||||
impactDescription: 80-90% faster transpilation with bundlers
|
||||
tags: tscfg, isolatedModules, transpilation, bundlers, performance
|
||||
---
|
||||
|
||||
## Use isolatedModules for Single-File Transpilation
|
||||
|
||||
The `isolatedModules` flag ensures each file can be transpiled independently, enabling parallel transpilation by bundlers like esbuild, swc, or Babel. This bypasses TypeScript's slower multi-file analysis.
|
||||
|
||||
**Incorrect (requires cross-file analysis):**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// constants.ts
|
||||
export const enum Status {
|
||||
Active = 'active',
|
||||
Inactive = 'inactive'
|
||||
}
|
||||
|
||||
// user.ts
|
||||
import { Status } from './constants'
|
||||
const status = Status.Active // Requires reading constants.ts to inline
|
||||
```
|
||||
|
||||
**Correct (single-file transpilable):**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// constants.ts
|
||||
export enum Status {
|
||||
// Regular enum, not const enum
|
||||
Active = 'active',
|
||||
Inactive = 'inactive'
|
||||
}
|
||||
|
||||
// user.ts
|
||||
import { Status } from './constants'
|
||||
const status = Status.Active // Reference preserved, no cross-file read
|
||||
```
|
||||
|
||||
**Build pipeline integration:**
|
||||
|
||||
```javascript
|
||||
// vite.config.ts
|
||||
export default {
|
||||
esbuild: {
|
||||
// esbuild transpiles files in parallel
|
||||
// TypeScript only runs type-checking
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Code patterns blocked by isolatedModules:**
|
||||
|
||||
- `const enum` (use regular `enum` or union types instead)
|
||||
- `export =` / `import =` syntax
|
||||
- Re-exporting types without `type` keyword
|
||||
|
||||
**Note:** With `erasableSyntaxOnly` (TypeScript 5.8+), regular enums are also blocked. Use union types (`type Status = 'active' | 'inactive'`) as the universal safe alternative. See `tscfg-erasable-syntax-only.md`.
|
||||
|
||||
Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#isolated-file-emit)
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
title: Enable isolatedDeclarations for Parallel Declaration Emit
|
||||
impact: CRITICAL
|
||||
impactDescription: enables parallel .d.ts generation without type-checker
|
||||
tags: tscfg, isolatedDeclarations, declarations, parallel, performance
|
||||
---
|
||||
|
||||
## Enable isolatedDeclarations for Parallel Declaration Emit
|
||||
|
||||
The `isolatedDeclarations` flag (TypeScript 5.5+) ensures each file's exports are annotated sufficiently for tools to generate `.d.ts` files without running the type-checker. This enables parallel declaration emit via bundlers and dramatically speeds up builds in large codebases.
|
||||
|
||||
**Incorrect (declaration emit requires full type-check):**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"declaration": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// utils.ts
|
||||
export function calculateTotal(items: CartItem[]) {
|
||||
// Return type inferred — requires type-checker to generate .d.ts
|
||||
return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (explicit annotations enable parallel emit):**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"isolatedDeclarations": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// utils.ts
|
||||
export function calculateTotal(items: CartItem[]): number {
|
||||
// Explicit return type — .d.ts can be generated per-file, in parallel
|
||||
return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||
}
|
||||
```
|
||||
|
||||
**What requires annotation under isolatedDeclarations:**
|
||||
|
||||
- Exported function return types
|
||||
- Exported variable types when not inferable from a literal
|
||||
- Exported class method return types
|
||||
|
||||
**What does NOT need annotation:**
|
||||
|
||||
- Local variables and functions (not exported)
|
||||
- Function parameters (already required by TypeScript)
|
||||
- Exports initialized with literals (`export const MAX = 100` is fine)
|
||||
|
||||
**Pair with isolatedModules for maximum build speed:**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"isolatedDeclarations": true,
|
||||
"declaration": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [TypeScript 5.5 - Isolated Declarations](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-5.html#isolated-declarations)
|
||||
@@ -1,96 +0,0 @@
|
||||
---
|
||||
title: Use Project References for Large Codebases
|
||||
impact: CRITICAL
|
||||
impactDescription: 60-80% faster incremental builds
|
||||
tags: tscfg, project-references, monorepo, tsconfig, compilation
|
||||
---
|
||||
|
||||
## Use Project References for Large Codebases
|
||||
|
||||
Project references split a codebase into independent compilation units. Each project compiles separately, enabling parallel builds and preventing the compiler from loading the entire codebase at once.
|
||||
|
||||
**Incorrect (monolithic tsconfig):**
|
||||
|
||||
```text
|
||||
my-app/
|
||||
├── tsconfig.json # Single config for entire app
|
||||
├── packages/
|
||||
│ ├── api/src/
|
||||
│ ├── web/src/
|
||||
│ └── shared/src/
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": { "outDir": "dist" },
|
||||
"include": ["packages/*/src/**/*"]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Loads ALL files into memory for every change
|
||||
# Change in api/ triggers full recompile
|
||||
```
|
||||
|
||||
**Correct (project references):**
|
||||
|
||||
```text
|
||||
my-app/
|
||||
├── tsconfig.json # Root config with references
|
||||
├── packages/
|
||||
│ ├── api/
|
||||
│ │ └── tsconfig.json # References shared
|
||||
│ ├── web/
|
||||
│ │ └── tsconfig.json # References shared
|
||||
│ └── shared/
|
||||
│ └── tsconfig.json # No references (leaf)
|
||||
```
|
||||
|
||||
```json
|
||||
// packages/shared/tsconfig.json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// packages/api/tsconfig.json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"references": [{ "path": "../shared" }],
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// tsconfig.json (root)
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "packages/shared" },
|
||||
{ "path": "packages/api" },
|
||||
{ "path": "packages/web" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
tsc --build # Builds only changed projects
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Parallel compilation of independent projects
|
||||
- Change in `shared/` only rebuilds dependents
|
||||
- Declaration files used as API boundaries
|
||||
|
||||
Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#using-project-references)
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
title: Enable skipLibCheck for Faster Builds
|
||||
impact: CRITICAL
|
||||
impactDescription: 20-40% faster compilation
|
||||
tags: tscfg, skipLibCheck, tsconfig, declaration-files, performance
|
||||
---
|
||||
|
||||
## Enable skipLibCheck for Faster Builds
|
||||
|
||||
The `skipLibCheck` option skips type-checking of declaration files (`.d.ts`). Since these files are pre-verified by library authors, checking them is redundant and wastes compilation time.
|
||||
|
||||
**Incorrect (checks all declaration files):**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Checks thousands of .d.ts files in node_modules
|
||||
# Compilation time: 25 seconds
|
||||
```
|
||||
|
||||
**Correct (skips declaration file checks):**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Only checks your source files
|
||||
# Compilation time: 15 seconds (40% faster)
|
||||
```
|
||||
|
||||
**Alternative (more conservative):**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"skipDefaultLibCheck": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This only skips checking the default library files (lib.d.ts), not third-party declarations.
|
||||
|
||||
**When to disable skipLibCheck:**
|
||||
|
||||
- Debugging type conflicts between declaration files
|
||||
- Publishing a library where you want to verify `.d.ts` output
|
||||
- Encountering mysterious type errors that might originate in declarations
|
||||
|
||||
Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#skipping-d-ts-checking)
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
title: Enable strictFunctionTypes for Faster Variance Checks
|
||||
impact: CRITICAL
|
||||
impactDescription: enables optimized variance checking
|
||||
tags: tscfg, strict, strictFunctionTypes, variance, performance
|
||||
---
|
||||
|
||||
## Enable strictFunctionTypes for Faster Variance Checks
|
||||
|
||||
With `strictFunctionTypes` enabled, TypeScript uses fast variance-based checking for function parameters. Without it, TypeScript falls back to slower structural comparison for every function type.
|
||||
|
||||
**Incorrect (slow structural checking):**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": false,
|
||||
"strictFunctionTypes": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
type Handler<T> = (event: T) => void
|
||||
|
||||
// Without strictFunctionTypes, TypeScript uses bidirectional
|
||||
// (bivariant) checking - comparing structures both ways
|
||||
const handler: Handler<MouseEvent> = (e: Event) => {} // Allowed but unsafe
|
||||
```
|
||||
|
||||
**Correct (fast variance checking):**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
type Handler<T> = (event: T) => void
|
||||
|
||||
// With strictFunctionTypes, TypeScript uses contravariant
|
||||
// checking for parameters - faster and type-safe
|
||||
const handler: Handler<MouseEvent> = (e: Event) => {} // Error: Event is not MouseEvent
|
||||
```
|
||||
|
||||
**Note:** The `strict` flag enables `strictFunctionTypes` along with other strict options. Enable `strict` for all new projects.
|
||||
|
||||
**When bivariance is needed:**
|
||||
|
||||
```typescript
|
||||
// Use method syntax for intentional bivariance
|
||||
interface EventEmitter<T> {
|
||||
emit(event: T): void // Method syntax = bivariant
|
||||
}
|
||||
|
||||
// vs property syntax for contravariance
|
||||
interface StrictEmitter<T> {
|
||||
emit: (event: T) => void // Property syntax = contravariant
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [TypeScript 2.6 - Strict Function Types](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-6.html#strict-function-types)
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
title: Avoid Deeply Nested Generic Types
|
||||
impact: CRITICAL
|
||||
impactDescription: prevents exponential instantiation cost
|
||||
tags: type, generics, nesting, instantiation, performance
|
||||
---
|
||||
|
||||
## Avoid Deeply Nested Generic Types
|
||||
|
||||
Each layer of generic nesting multiplies type instantiation cost. Flatten generic hierarchies or use intermediate type aliases to reduce the combinatorial explosion of type checking.
|
||||
|
||||
**Incorrect (deeply nested generics):**
|
||||
|
||||
```typescript
|
||||
type ApiResponse<T> = {
|
||||
data: T
|
||||
meta: ResponseMeta
|
||||
}
|
||||
|
||||
type PaginatedResponse<T> = ApiResponse<{
|
||||
items: T[]
|
||||
pagination: PaginationInfo
|
||||
}>
|
||||
|
||||
type CachedResponse<T> = PaginatedResponse<{
|
||||
value: T
|
||||
cachedAt: Date
|
||||
}>
|
||||
|
||||
// Usage creates 4+ levels of nesting
|
||||
function fetchUsers(): CachedResponse<User> {}
|
||||
// Compiler must resolve: CachedResponse<User> → PaginatedResponse<...> → ApiResponse<...>
|
||||
```
|
||||
|
||||
**Correct (flattened with composition):**
|
||||
|
||||
```typescript
|
||||
interface PaginationInfo {
|
||||
page: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
interface CacheInfo {
|
||||
cachedAt: Date
|
||||
}
|
||||
|
||||
interface PaginatedData<T> {
|
||||
items: T[]
|
||||
pagination: PaginationInfo
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data: T
|
||||
meta: ResponseMeta
|
||||
}
|
||||
|
||||
// Compose at usage site instead of nesting
|
||||
type UserListResponse = ApiResponse<PaginatedData<User> & CacheInfo>
|
||||
|
||||
function fetchUsers(): UserListResponse {}
|
||||
// Single-level generic instantiation
|
||||
```
|
||||
|
||||
**Alternative (builder pattern for complex responses):**
|
||||
|
||||
```typescript
|
||||
interface ResponseBuilder<T> {
|
||||
data: T
|
||||
meta: ResponseMeta
|
||||
}
|
||||
|
||||
function withPagination<T>(items: T[], pagination: PaginationInfo): PaginatedData<T> {
|
||||
return { items, pagination }
|
||||
}
|
||||
|
||||
function withCache<T>(value: T): T & CacheInfo {
|
||||
return { ...value, cachedAt: new Date() }
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance)
|
||||
@@ -1,62 +0,0 @@
|
||||
---
|
||||
title: Avoid Large Union Types
|
||||
impact: CRITICAL
|
||||
impactDescription: quadratic O(n²) comparison cost
|
||||
tags: type, unions, compilation, performance, discriminated-unions
|
||||
---
|
||||
|
||||
## Avoid Large Union Types
|
||||
|
||||
Union type checking is quadratic — TypeScript compares each union member pairwise. Unions with 50+ elements cause measurable compilation slowdowns and IDE lag. This commonly occurs with generated types (GraphQL schemas, API response codes, database enums).
|
||||
|
||||
**Incorrect (large generated union, O(n²) checks):**
|
||||
|
||||
```typescript
|
||||
// Auto-generated from GraphQL schema — 200+ event types
|
||||
type AnalyticsEvent =
|
||||
| 'page_view'
|
||||
| 'button_click'
|
||||
| 'form_submit'
|
||||
| 'scroll_depth'
|
||||
| 'video_play'
|
||||
| 'video_pause'
|
||||
| 'video_complete'
|
||||
| 'ad_impression'
|
||||
// ... 200 more event types from analytics schema
|
||||
// 200 members = 40,000 pairwise comparisons per usage
|
||||
```
|
||||
|
||||
**Correct (branded string type with runtime validation):**
|
||||
|
||||
```typescript
|
||||
type AnalyticsEvent = string & { readonly __brand: 'AnalyticsEvent' }
|
||||
|
||||
const VALID_EVENTS = new Set(['page_view', 'button_click', 'form_submit' /* ... */])
|
||||
|
||||
function createEvent(name: string): AnalyticsEvent {
|
||||
if (!VALID_EVENTS.has(name)) {
|
||||
throw new Error(`Unknown event: ${name}`)
|
||||
}
|
||||
return name as AnalyticsEvent
|
||||
}
|
||||
```
|
||||
|
||||
**For moderately large unions (20-50 members), use discriminated unions:**
|
||||
|
||||
```typescript
|
||||
// Group related values into categories
|
||||
type UserEvent = { category: 'user'; action: 'login' | 'logout' | 'signup' }
|
||||
type PageEvent = { category: 'page'; action: 'view' | 'scroll' | 'leave' }
|
||||
type FormEvent = { category: 'form'; action: 'submit' | 'validate' | 'reset' }
|
||||
|
||||
type AppEvent = UserEvent | PageEvent | FormEvent
|
||||
// Small union of 3 interfaces instead of 9+ string literals
|
||||
```
|
||||
|
||||
**When flat unions are fine:**
|
||||
|
||||
- Small unions (< 20 members) have negligible cost
|
||||
- Unions of primitive literals used in few places
|
||||
- `string | number | boolean` style utility unions
|
||||
|
||||
Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#preferring-base-types-over-unions)
|
||||
@@ -1,66 +0,0 @@
|
||||
---
|
||||
title: Add Explicit Return Types to Exported Functions
|
||||
impact: CRITICAL
|
||||
impactDescription: 30-50% faster declaration emit
|
||||
tags: type, return-types, exports, inference, performance
|
||||
---
|
||||
|
||||
## Add Explicit Return Types to Exported Functions
|
||||
|
||||
Explicit return types accelerate compilation by eliminating inference overhead. Named types are more compact than inferred anonymous types, speeding up declaration file generation and consumption.
|
||||
|
||||
**Incorrect (inferred return type, slow declaration emit):**
|
||||
|
||||
```typescript
|
||||
export function fetchUserProfile(userId: string) {
|
||||
// Compiler must analyze entire function body to infer return type
|
||||
return fetch(`/api/users/${userId}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => ({
|
||||
id: data.id as string,
|
||||
name: data.name as string,
|
||||
email: data.email as string,
|
||||
createdAt: new Date(data.created_at),
|
||||
permissions: data.permissions as Permission[]
|
||||
}))
|
||||
}
|
||||
// Inferred: Promise<{ id: string; name: string; email: string; createdAt: Date; permissions: Permission[] }>
|
||||
```
|
||||
|
||||
**Correct (explicit return type, fast compilation):**
|
||||
|
||||
```typescript
|
||||
interface UserProfile {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
createdAt: Date
|
||||
permissions: Permission[]
|
||||
}
|
||||
|
||||
export function fetchUserProfile(userId: string): Promise<UserProfile> {
|
||||
return fetch(`/api/users/${userId}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => ({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
createdAt: new Date(data.created_at),
|
||||
permissions: data.permissions
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
**When to skip explicit return types:**
|
||||
|
||||
- Private/internal functions with simple returns
|
||||
- Arrow functions in local scope
|
||||
- Functions where the return type is obvious (e.g., `(): void`)
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Declaration files use named type instead of expanded inline type
|
||||
- Faster incremental compilation when function body changes
|
||||
- Better error messages pointing to return type mismatch
|
||||
|
||||
Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#using-type-annotations)
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
title: Extract Conditional Types to Named Aliases
|
||||
impact: CRITICAL
|
||||
impactDescription: enables compiler caching, prevents re-evaluation
|
||||
tags: type, conditional-types, generics, caching, performance
|
||||
---
|
||||
|
||||
## Extract Conditional Types to Named Aliases
|
||||
|
||||
Inline conditional types are re-evaluated on every function call. Extracting them to named type aliases allows the compiler to cache results and reuse them across multiple call sites.
|
||||
|
||||
**Incorrect (inline conditional, re-evaluated each call):**
|
||||
|
||||
```typescript
|
||||
function processResponse<T>(
|
||||
response: T
|
||||
): T extends { data: infer D } ? (D extends Array<infer Item> ? Item[] : D) : never {
|
||||
// Compiler re-computes this complex conditional on every call
|
||||
return response.data
|
||||
}
|
||||
|
||||
function getFirstItem<T>(collection: T): T extends Array<infer U> ? U : T {
|
||||
// Re-evaluated for each getFirstItem() usage
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (extracted, cacheable):**
|
||||
|
||||
```typescript
|
||||
type ExtractData<T> = T extends { data: infer D }
|
||||
? D extends Array<infer Item>
|
||||
? Item[]
|
||||
: D
|
||||
: never
|
||||
|
||||
function processResponse<T>(response: T): ExtractData<T> {
|
||||
// Compiler caches ExtractData<T> resolution
|
||||
return response.data
|
||||
}
|
||||
|
||||
type UnwrapArray<T> = T extends Array<infer U> ? U : T
|
||||
|
||||
function getFirstItem<T>(collection: T): UnwrapArray<T> {
|
||||
// Reuses cached UnwrapArray<T> computation
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Type alias acts as a cache boundary
|
||||
- Reduces duplicate computation across multiple call sites
|
||||
- Improves IDE responsiveness for autocomplete
|
||||
|
||||
Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#using-type-aliases)
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
title: Prefer Interfaces Over Type Intersections
|
||||
impact: CRITICAL
|
||||
impactDescription: 2-5× faster type resolution
|
||||
tags: type, interfaces, intersections, compilation, performance
|
||||
---
|
||||
|
||||
## Prefer Interfaces Over Type Intersections
|
||||
|
||||
Interfaces create a single flat object type that detects property conflicts at declaration. Intersections recursively merge properties on every use, forcing the compiler to recompute the combined type repeatedly.
|
||||
|
||||
**Incorrect (recursive intersection merging):**
|
||||
|
||||
```typescript
|
||||
type UserWithPermissions = User & Permissions & AuditInfo
|
||||
// Compiler merges all properties on every reference
|
||||
|
||||
type ExtendedOrder = Order & {
|
||||
metadata: OrderMetadata
|
||||
} & Timestamps
|
||||
// Each intersection adds another layer of computation
|
||||
```
|
||||
|
||||
**Correct (single flat interface):**
|
||||
|
||||
```typescript
|
||||
interface UserWithPermissions extends User, Permissions, AuditInfo {}
|
||||
// Single flat type, computed once
|
||||
|
||||
interface ExtendedOrder extends Order, Timestamps {
|
||||
metadata: OrderMetadata
|
||||
}
|
||||
// Extends create efficient inheritance chain
|
||||
```
|
||||
|
||||
**When to use intersections:**
|
||||
|
||||
- Combining function types or primitives (interfaces cannot extend these)
|
||||
- Creating mapped or conditional types
|
||||
- One-off type combinations not reused elsewhere
|
||||
|
||||
Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance#preferring-interfaces-over-intersections)
|
||||
@@ -1,59 +0,0 @@
|
||||
---
|
||||
title: Limit Type Recursion Depth
|
||||
impact: HIGH
|
||||
impactDescription: prevents exponential type expansion when applicable
|
||||
tags: type, recursion, generics, depth, performance
|
||||
---
|
||||
|
||||
## Limit Type Recursion Depth
|
||||
|
||||
Recursive types without depth limits can cause exponential type expansion, leading to compilation hangs or out-of-memory errors. Add explicit depth counters or use tail-recursive patterns.
|
||||
|
||||
**Incorrect (unbounded recursion):**
|
||||
|
||||
```typescript
|
||||
type DeepPartial<T> = {
|
||||
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
|
||||
}
|
||||
// No depth limit - deeply nested objects cause exponential expansion
|
||||
|
||||
type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue }
|
||||
// Infinite recursion potential
|
||||
```
|
||||
|
||||
**Correct (bounded recursion with depth counter):**
|
||||
|
||||
```typescript
|
||||
type DeepPartial<T, Depth extends number[] = []> = Depth['length'] extends 5
|
||||
? T // Stop at depth 5
|
||||
: {
|
||||
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P], [...Depth, 1]> : T[P]
|
||||
}
|
||||
|
||||
type JSONValue<Depth extends number[] = []> = Depth['length'] extends 10
|
||||
? unknown
|
||||
:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| JSONValue<[...Depth, 1]>[]
|
||||
| { [key: string]: JSONValue<[...Depth, 1]> }
|
||||
```
|
||||
|
||||
**Alternative (use built-in utilities):**
|
||||
|
||||
```typescript
|
||||
// For simple cases, prefer built-in Partial over custom DeepPartial
|
||||
type Config = Partial<AppConfig>
|
||||
|
||||
// Use libraries like ts-toolbelt for complex recursive types
|
||||
// They implement optimized depth-limited versions
|
||||
```
|
||||
|
||||
**When unbounded recursion is acceptable:**
|
||||
|
||||
- Types with guaranteed shallow depth (max 2-3 levels)
|
||||
- Internal types not exposed in public APIs
|
||||
|
||||
Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance)
|
||||
@@ -1,67 +0,0 @@
|
||||
---
|
||||
title: Simplify Complex Mapped Types
|
||||
impact: HIGH
|
||||
impactDescription: reduces type computation by 50-80% when applicable
|
||||
tags: type, mapped-types, simplification, utility-types, performance
|
||||
---
|
||||
|
||||
## Simplify Complex Mapped Types
|
||||
|
||||
Overly complex mapped types with multiple conditional branches slow compilation significantly. Break them into smaller, focused utility types and compose them.
|
||||
|
||||
**Incorrect (monolithic mapped type):**
|
||||
|
||||
```typescript
|
||||
type ComplexTransform<T> = {
|
||||
[K in keyof T]: T[K] extends Function
|
||||
? T[K]
|
||||
: T[K] extends Array<infer U>
|
||||
? U extends object
|
||||
? ComplexTransform<U>[]
|
||||
: T[K]
|
||||
: T[K] extends object
|
||||
? T[K] extends Date
|
||||
? string
|
||||
: ComplexTransform<T[K]>
|
||||
: T[K] extends number
|
||||
? string
|
||||
: T[K]
|
||||
}
|
||||
// Multiple nested conditionals evaluated for every property
|
||||
```
|
||||
|
||||
**Correct (composed utility types):**
|
||||
|
||||
```typescript
|
||||
type TransformValue<T> = T extends Date ? string : T extends number ? string : T
|
||||
|
||||
type TransformObject<T> = {
|
||||
[K in keyof T]: TransformProperty<T[K]>
|
||||
}
|
||||
|
||||
type TransformProperty<T> = T extends Function
|
||||
? T
|
||||
: T extends Array<infer U>
|
||||
? TransformArray<U>
|
||||
: T extends object
|
||||
? TransformObject<T>
|
||||
: TransformValue<T>
|
||||
|
||||
type TransformArray<T> = T extends object ? TransformObject<T>[] : T[]
|
||||
|
||||
// Each utility is cached independently
|
||||
type TransformedUser = TransformObject<User>
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Each small utility type is cached separately
|
||||
- Easier to debug type errors
|
||||
- More reusable across the codebase
|
||||
|
||||
**When complex mapped types are acceptable:**
|
||||
|
||||
- Internal utility types used in few places
|
||||
- Types that genuinely require complex logic
|
||||
|
||||
Reference: [TypeScript Performance Wiki](https://github.com/microsoft/TypeScript/wiki/Performance)
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/auto-review-fix
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/electron
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/react-useeffect
|
||||
@@ -1,278 +0,0 @@
|
||||
---
|
||||
name: review-and-submit
|
||||
description: Lightweight review-fix loop (2 rounds, 1 agent each), then create PR and merge
|
||||
---
|
||||
|
||||
# review-and-submit
|
||||
|
||||
Lightweight autonomous pipeline: review code with a single agent, fix issues (up to 2 rounds), then create PR and merge. Designed for smaller PRs where full parallel review is overkill.
|
||||
|
||||
**IMPORTANT**: Execute this entire process autonomously without asking the user for confirmation at any step.
|
||||
|
||||
---
|
||||
|
||||
## Process Overview
|
||||
|
||||
```
|
||||
REVIEW-FIX (max 2 rounds, 1 agent each) → CREATE PR → WAIT FOR CI → MERGE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Review-Fix Loop (Max 2 Rounds)
|
||||
|
||||
### Round Setup (first round only)
|
||||
|
||||
Get the diff to understand what changed:
|
||||
|
||||
```bash
|
||||
MERGE_BASE=$(git merge-base origin/main HEAD)
|
||||
git diff $MERGE_BASE --stat
|
||||
git diff $MERGE_BASE
|
||||
```
|
||||
|
||||
### Each Round: Single-Agent Review
|
||||
|
||||
Spawn **one** review agent that covers all review concerns:
|
||||
|
||||
```
|
||||
Agent(
|
||||
subagent_type: "general-purpose",
|
||||
model: "opus",
|
||||
description: "Review all changes on branch",
|
||||
prompt: """
|
||||
Review all code changes on this branch vs origin/main.
|
||||
|
||||
Run: git diff $(git merge-base origin/main HEAD)
|
||||
|
||||
Review for:
|
||||
- Correctness and logical bugs
|
||||
- Security issues
|
||||
- Type safety issues
|
||||
- Error handling gaps
|
||||
- Performance problems
|
||||
- Dead code or unused imports
|
||||
|
||||
**SCOPE**: Only report issues in changed code or directly caused by the changes.
|
||||
Do NOT report pre-existing issues unrelated to this PR.
|
||||
|
||||
For each finding, output:
|
||||
- File: path/to/file.ts
|
||||
- Line: NN
|
||||
- Severity: Critical|High|Medium|Low
|
||||
- Issue: [description]
|
||||
- Fix: [suggested fix]
|
||||
|
||||
If no issues found, say "No issues found."
|
||||
"""
|
||||
)
|
||||
```
|
||||
|
||||
### Fix Phase
|
||||
|
||||
If the review found issues:
|
||||
|
||||
1. **Skip Low severity issues** — only fix Critical, High, and Medium
|
||||
2. Fix issues via a single fix agent:
|
||||
|
||||
```
|
||||
Agent(
|
||||
subagent_type: "general-purpose",
|
||||
model: "opus",
|
||||
description: "Fix review issues",
|
||||
prompt: """
|
||||
Fix the following review issues:
|
||||
|
||||
[LIST ALL CRITICAL/HIGH/MEDIUM ISSUES FROM REVIEW]
|
||||
|
||||
Instructions:
|
||||
1. Read each file
|
||||
2. Apply fixes using the Edit tool
|
||||
3. Verify fixes don't break syntax
|
||||
4. Report what was fixed
|
||||
"""
|
||||
)
|
||||
```
|
||||
|
||||
3. After fixes, run typecheck:
|
||||
|
||||
```bash
|
||||
pnpm typecheck 2>&1
|
||||
```
|
||||
|
||||
If typecheck fails, fix type errors (up to 2 attempts).
|
||||
|
||||
### Exit Conditions
|
||||
|
||||
```
|
||||
IF (review found no Critical/High/Medium issues):
|
||||
→ EXIT loop — code is clean
|
||||
|
||||
IF (fixes applied):
|
||||
→ Run another round to verify (unless already at round 2)
|
||||
|
||||
IF (round 2 reached):
|
||||
→ EXIT loop
|
||||
```
|
||||
|
||||
### Commit Changes
|
||||
|
||||
After the review-fix loop completes, commit any changes:
|
||||
|
||||
```bash
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
git add -A && git commit -m "fix: address review findings"
|
||||
fi
|
||||
```
|
||||
|
||||
**Continue to Step 2 — do not stop here.**
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Create PR and Merge
|
||||
|
||||
### 2a. Remove local design docs
|
||||
|
||||
Design/planning docs are local-only scratch files and must not be checked in.
|
||||
Delete any untracked markdown files in `docs/` that match design/plan
|
||||
naming patterns before pushing:
|
||||
|
||||
```bash
|
||||
git ls-files --others --exclude-standard -- 'docs/*-design.md' 'docs/*-plan.md' 'docs/design-*.md' \
|
||||
| xargs -I{} rm -f {}
|
||||
```
|
||||
|
||||
### 2b. Create PR
|
||||
|
||||
**FIRST**: Push the branch to the remote so `gh pr create` doesn't fail with
|
||||
`aborted: you must first push the current branch to a remote`. The
|
||||
`create-pr` skill rebases locally but does not always push before invoking
|
||||
`gh pr create`, and `gh` refuses to create a PR for an un-pushed branch.
|
||||
|
||||
```bash
|
||||
git push --force-with-lease -u origin HEAD
|
||||
```
|
||||
|
||||
Then invoke:
|
||||
|
||||
```
|
||||
Use the Skill tool: skill: "create-pr"
|
||||
```
|
||||
|
||||
After `/create-pr` completes, extract PR info:
|
||||
|
||||
```bash
|
||||
gh pr view --json number,url --jq '.number, .url'
|
||||
```
|
||||
|
||||
### 2c. Wait for CI Checks
|
||||
|
||||
Wait **2.5 minutes** before the first poll:
|
||||
|
||||
```bash
|
||||
echo "Waiting 2.5 minutes for CI checks..." && sleep 150
|
||||
```
|
||||
|
||||
**CRITICAL**: Set `timeout: 210000` (3.5 minutes) on the Bash tool call for this sleep.
|
||||
|
||||
### Poll for results (up to 5 polls, 90s apart)
|
||||
|
||||
For each poll:
|
||||
|
||||
```bash
|
||||
CHECKS=$(gh pr checks --json name,state,bucket 2>&1)
|
||||
echo "$CHECKS"
|
||||
|
||||
if echo "$CHECKS" | jq -e 'length == 0' >/dev/null 2>&1; then
|
||||
echo "RESULT:NO_CHECKS"
|
||||
elif echo "$CHECKS" | jq -e '[.[].bucket] | all(. == "pass" or . == "skipping")' >/dev/null 2>&1; then
|
||||
echo "RESULT:PASSED"
|
||||
elif echo "$CHECKS" | jq -e '[.[].bucket] | any(. == "fail" or . == "cancel")' >/dev/null 2>&1; then
|
||||
echo "RESULT:FAILED"
|
||||
else
|
||||
echo "RESULT:PENDING"
|
||||
fi
|
||||
```
|
||||
|
||||
- `PASSED` → Proceed to merge
|
||||
- `FAILED` → Attempt one quick fix iteration (diagnose from `gh run view --log-failed`, fix, push, re-poll)
|
||||
- `PENDING` → Wait 90s (`sleep 90`, `timeout: 150000`), then poll again
|
||||
- `NO_CHECKS` → Proceed to merge
|
||||
|
||||
### 2d. Merge
|
||||
|
||||
```bash
|
||||
IS_WORKTREE=false
|
||||
if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]; then
|
||||
IS_WORKTREE=true
|
||||
fi
|
||||
|
||||
BRANCH=$(git branch --show-current)
|
||||
|
||||
if [ "$IS_WORKTREE" = "true" ]; then
|
||||
gh pr merge --admin --squash
|
||||
git push origin --delete "$BRANCH" 2>/dev/null || true
|
||||
else
|
||||
gh pr merge --admin --squash --delete-branch
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI Fix Loop (if checks fail)
|
||||
|
||||
If CI fails after PR creation, run **one** fix iteration:
|
||||
|
||||
1. Diagnose:
|
||||
|
||||
```bash
|
||||
FAILED_RUN=$(gh run list --branch $(git branch --show-current) --limit 5 --json databaseId,conclusion,name --jq '[.[] | select(.conclusion == "failure")] | .[0].databaseId')
|
||||
if [ -n "$FAILED_RUN" ] && [ "$FAILED_RUN" != "null" ]; then
|
||||
gh run view "$FAILED_RUN" --log-failed 2>&1 | tail -200
|
||||
fi
|
||||
```
|
||||
|
||||
2. Fix the issues, run `pnpm typecheck`, commit and push:
|
||||
|
||||
```bash
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
git add -A && git commit -m "fix: address CI failures"
|
||||
git push --force-with-lease
|
||||
fi
|
||||
```
|
||||
|
||||
3. Wait 2.5 minutes and re-poll. If still failing after one fix attempt, merge with `--admin` anyway.
|
||||
|
||||
---
|
||||
|
||||
## Progress Tracking
|
||||
|
||||
```
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ REVIEW AND SUBMIT ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ Review-Fix Loop: ║
|
||||
║ Round 1: ✅ X issues found, Y fixed ║
|
||||
║ Round 2: ✅ Clean review ║
|
||||
║ Create PR: ✅ PR #123 ║
|
||||
║ CI Checks: ✅ Passed ║
|
||||
║ Merge: ✅ Merged with --admin --squash ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ PR URL: https://github.com/org/repo/pull/123 ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Instructions
|
||||
|
||||
1. **DO NOT ask for user confirmation** — Execute autonomously
|
||||
2. **Max 2 review rounds**, 1 agent per round — keep it lightweight
|
||||
3. **Only fix Critical/High/Medium** — skip Low severity
|
||||
4. **DO run typecheck** after fixes
|
||||
5. **DO use opus model** for review and fix agents
|
||||
6. **2.5 minute initial CI wait** (not 8 minutes) — this repo's CI is fast
|
||||
7. **DO use --admin --squash** for merge
|
||||
8. **DO set Bash timeouts** for sleep commands
|
||||
9. **Continue through all steps** — don't stop after review or PR creation
|
||||
10. **One CI fix iteration max** — don't loop forever on CI failures
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/typescript
|
||||
@@ -1,9 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
@@ -80,7 +80,7 @@ All releases are cut from the **Cut Release** GitHub Actions workflow. There is
|
||||
- **ref**: the branch, tag, or SHA to build from. Defaults to `main`.
|
||||
3. Run it.
|
||||
|
||||
The workflow resolves the next version from GitHub Releases, bumps `package.json`, tags, pushes, and kicks off the multi-platform build via `release.yml`.
|
||||
The workflow resolves the next version from GitHub Releases, bumps `package.json`, tags, pushes, and runs the multi-platform build + publish inline.
|
||||
|
||||
**How the next version is chosen:**
|
||||
|
||||
@@ -33,13 +33,25 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
# Why: this job runs the same pnpm install path as pr.yml's verify
|
||||
# job, so it needs the same pinned node-gyp override to avoid pnpm's
|
||||
# broken bundled gyp_main.py on Linux. Gate on runner.os matches
|
||||
# release.yml so the invariant "this workaround is Linux-only" is
|
||||
# consistent across all three workflows, even though this job
|
||||
# currently pins runs-on: ubuntu-latest.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
name: Homebrew Cask Bump
|
||||
|
||||
# Why: keep the stablyai/homebrew-orca tap in sync with GitHub Releases.
|
||||
# Triggers:
|
||||
# - workflow_call → release-cut.yml finished publishing a stable tag
|
||||
# - workflow_dispatch → manual backfill for a specific tag
|
||||
#
|
||||
# The job computes the sha256 of orca-macos-arm64.dmg + orca-macos-x64.dmg
|
||||
# from the release, templates Casks/orca.rb in this repo with the new
|
||||
# version and hashes, then PRs the result into stablyai/homebrew-orca. It
|
||||
# does NOT run for -rc.* tags: the stable cask should only track GA.
|
||||
#
|
||||
# Why no `release.published` trigger: other products shipped from this repo
|
||||
# (e.g. mobile under `mobile-v*`) also publish GitHub Releases. A blanket
|
||||
# `release.published` trigger would fire this workflow for any of them and
|
||||
# attempt to download `orca-macos-*.dmg` from a non-desktop release, at
|
||||
# best 404ing and at worst (if the filter were weaker) rewriting the cask
|
||||
# to a non-desktop version. The tap is only ever bumped by the desktop
|
||||
# release pipeline calling this workflow directly.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to sync (e.g. v1.3.25)
|
||||
required: true
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to sync (e.g. v1.3.25)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
bump-cask:
|
||||
runs-on: ubuntu-latest
|
||||
# Why: defense in depth. The desktop release pipeline already refuses
|
||||
# to invoke this workflow for `-rc.*` tags, but a manual backfill could
|
||||
# still pass one in — reject it here so the stable cask can never point
|
||||
# at an RC. Also requires the desktop tag shape `v<digit>...` so a
|
||||
# mis-typed mobile tag like `mobile-v0.0.1` is rejected up front.
|
||||
if: >
|
||||
inputs.tag != '' &&
|
||||
!contains(inputs.tag, '-rc.') &&
|
||||
startsWith(inputs.tag, 'v')
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout orca
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve tag and version
|
||||
id: tag
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "$INPUT_TAG" ]]; then
|
||||
tag="$INPUT_TAG"
|
||||
else
|
||||
tag="$RELEASE_TAG"
|
||||
fi
|
||||
version="${tag#v}"
|
||||
echo "tag=$tag" >>"$GITHUB_OUTPUT"
|
||||
echo "version=$version" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download DMGs and compute sha256
|
||||
id: hash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dmgs
|
||||
gh release download "$TAG" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--pattern 'orca-macos-arm64.dmg' \
|
||||
--pattern 'orca-macos-x64.dmg' \
|
||||
--dir dmgs
|
||||
arm=$(sha256sum dmgs/orca-macos-arm64.dmg | awk '{print $1}')
|
||||
intel=$(sha256sum dmgs/orca-macos-x64.dmg | awk '{print $1}')
|
||||
echo "arm=$arm" >>"$GITHUB_OUTPUT"
|
||||
echo "intel=$intel" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Render updated cask file
|
||||
env:
|
||||
VERSION: ${{ steps.tag.outputs.version }}
|
||||
ARM_SHA: ${{ steps.hash.outputs.arm }}
|
||||
INTEL_SHA: ${{ steps.hash.outputs.intel }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
import os, re, pathlib
|
||||
p = pathlib.Path("Casks/orca.rb")
|
||||
src = p.read_text()
|
||||
src = re.sub(r'version "[^"]+"',
|
||||
f'version "{os.environ["VERSION"]}"', src, count=1)
|
||||
src = re.sub(r'sha256 arm:\s*"[0-9a-f]+",\s*\n\s*intel:\s*"[0-9a-f]+"',
|
||||
'sha256 arm: "{arm}",\n intel: "{intel}"'.format(
|
||||
arm=os.environ["ARM_SHA"],
|
||||
intel=os.environ["INTEL_SHA"]),
|
||||
src, count=1)
|
||||
p.write_text(src)
|
||||
print(src)
|
||||
PY
|
||||
|
||||
# Why: reuse the existing buf0-bot GitHub App (also used by
|
||||
# track-community-prs.yaml) instead of minting a new PAT. The app is
|
||||
# installed org-wide, so it has access to stablyai/homebrew-orca
|
||||
# automatically. GITHUB_TOKEN cannot push cross-repo; a short-lived
|
||||
# installation token can.
|
||||
- name: Generate buf0-bot token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: 2590194
|
||||
private-key: ${{ secrets.BUFO_BOT_PRIVATE_KEY }}
|
||||
owner: stablyai
|
||||
repositories: homebrew-orca
|
||||
|
||||
- name: Checkout tap
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: stablyai/homebrew-orca
|
||||
path: tap
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Copy cask into tap and open PR
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
VERSION: ${{ steps.tag.outputs.version }}
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p tap/Casks
|
||||
cp Casks/orca.rb tap/Casks/orca.rb
|
||||
|
||||
cd tap
|
||||
# Why: commit author must match the buf0-bot app so commits are
|
||||
# attributed to the bot in the tap's history / PR UI.
|
||||
git config user.name "buf0-bot[bot]"
|
||||
git config user.email "252831055+buf0-bot[bot]@users.noreply.github.com"
|
||||
|
||||
if git diff --quiet; then
|
||||
echo "Cask already up-to-date for $TAG; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
branch="bump/orca-$VERSION"
|
||||
git checkout -b "$branch"
|
||||
git add Casks/orca.rb
|
||||
git commit -m "orca $VERSION"
|
||||
git push -u origin "$branch" --force
|
||||
|
||||
# Why: prefer --fill so the PR body matches the commit; fall back
|
||||
# to a brief body if the PR already exists for this branch.
|
||||
gh pr create \
|
||||
--title "orca $VERSION" \
|
||||
--body "Automated bump from stablyai/orca release $TAG." \
|
||||
--base main \
|
||||
--head "$branch" \
|
||||
|| gh pr edit "$branch" --title "orca $VERSION"
|
||||
|
||||
# Why: squash-merge immediately rather than --auto. The tap has no
|
||||
# required checks or branch protection, and `gh pr merge --auto`
|
||||
# only activates when there's something to wait on — on a repo
|
||||
# with no gates it silently no-ops, leaving the PR open forever.
|
||||
gh pr merge "$branch" --squash --delete-branch
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Mobile Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'mobile-v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
android-build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Why: the "Create GitHub Release" step below uses the default GITHUB_TOKEN
|
||||
# to call `gh release create`, which requires contents:write. Without this,
|
||||
# the job builds the APK fine but fails at release time with
|
||||
# "HTTP 403: Resource not accessible by integration".
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mobile
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Expo prebuild
|
||||
run: npx expo prebuild --platform android --no-install
|
||||
|
||||
- name: Build Android release APK
|
||||
run: cd android && ./gradlew assembleRelease
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: orca-mobile-apk
|
||||
path: mobile/android/app/build/outputs/apk/release/*.apk
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/mobile-v')
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
gh release create "$tag" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "Orca Mobile $tag" \
|
||||
--prerelease \
|
||||
--latest=false \
|
||||
--generate-notes \
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Mobile Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- 'mobile/**'
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mobile
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
@@ -22,13 +22,29 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
# Why: pnpm's bundled node-gyp ships gyp_main.py without execute
|
||||
# permission, which breaks native module builds (e.g. node-pty's
|
||||
# postinstall) with "/bin/sh: gyp_main.py: Permission denied".
|
||||
# Pin the fallback to the lockfile's node-gyp version so CI stays
|
||||
# reproducible while forcing pnpm to bypass its broken bundled copy.
|
||||
# Gate on runner.os == 'Linux' to match release.yml — the
|
||||
# npm-global path layout this step assumes is POSIX-shaped, and the
|
||||
# failure has only been observed on Linux runners. Today this job
|
||||
# pins runs-on: ubuntu-latest so the guard is a no-op, but it
|
||||
# prevents a silent break if a Windows/macOS matrix is added later.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -55,6 +71,12 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
# Why: postinstall rebuilds better-sqlite3 for Electron's ABI via
|
||||
# @electron/rebuild, but vitest runs under system Node.js. Rebuild
|
||||
# it for Node so orchestration tests can load the native module.
|
||||
- name: Rebuild better-sqlite3 for Node
|
||||
run: pnpm rebuild better-sqlite3
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: Cut Release
|
||||
|
||||
# Why: single entry point for manually cutting a release from any ref.
|
||||
# Replaces the old local `pnpm release:*` scripts so releases are always
|
||||
# reproducible from CI and can never be accidentally tagged against an
|
||||
# uncommitted or non-main working tree.
|
||||
# Why: single entry point for manually and scheduled cutting releases.
|
||||
# Replaces the old local `pnpm release:*` scripts and the standalone scheduled
|
||||
# RC workflow so releases are always reproducible from CI and can never be
|
||||
# accidentally tagged against an uncommitted or non-main working tree.
|
||||
#
|
||||
# Flow:
|
||||
# 1. Resolve `ref` to a SHA.
|
||||
@@ -36,6 +36,17 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: main
|
||||
dry_run:
|
||||
description: Validate a scheduled-RC run without creating a tag
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
schedule:
|
||||
# Why: GitHub scheduled workflows are not guaranteed to start exactly on
|
||||
# time and can be delayed or dropped around high-load periods. We retry
|
||||
# across the full target hour and dedupe per PT slot. Cron is UTC, so run
|
||||
# during both PST and PDT equivalents and let the PT-hour gate decide.
|
||||
- cron: '*/5 10,11,22,23 * * *'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -49,18 +60,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
tag: ${{ steps.tag.outputs.tag }}
|
||||
tag: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }}
|
||||
should_release: ${{ steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }}
|
||||
steps:
|
||||
- name: Checkout ref
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
ref: ${{ github.event_name == 'schedule' && 'main' || inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Configure git author
|
||||
run: |
|
||||
@@ -84,28 +96,115 @@ jobs:
|
||||
echo "push_main=false" >>"$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Compute RC slot
|
||||
id: slot
|
||||
run: |
|
||||
slot=$(TZ=America/Los_Angeles date '+%Y-%m-%d-%H')
|
||||
echo "value=$slot" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate PT release window
|
||||
id: window
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
if [[ "$EVENT_NAME" != "schedule" ]]; then
|
||||
echo "allowed=true" >>"$GITHUB_OUTPUT"
|
||||
echo "reason=manual" >>"$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
pt_hour=$(TZ=America/Los_Angeles date '+%H')
|
||||
pt_minute=$(TZ=America/Los_Angeles date '+%M')
|
||||
|
||||
# Why: GitHub may deliver a scheduled event long after the intended
|
||||
# time, so delayed 4:16 AM runs must not cut the 3:00 AM release.
|
||||
if [[ "$pt_hour" == "03" || "$pt_hour" == "15" ]]; then
|
||||
echo "allowed=true" >>"$GITHUB_OUTPUT"
|
||||
echo "reason=target_hour:${pt_hour}:${pt_minute}" >>"$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "allowed=false" >>"$GITHUB_OUTPUT"
|
||||
echo "reason=outside_target_hour:${pt_hour}:${pt_minute}" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Skip if this PT release window already ran
|
||||
id: existing
|
||||
if: github.event_name == 'schedule' && steps.window.outputs.allowed == 'true'
|
||||
run: |
|
||||
# Why: scheduled runs retry inside each target hour, so make the
|
||||
# schedule idempotent by embedding a slot marker in the release commit.
|
||||
if git log origin/main --grep="\\[rc-slot:${{ steps.slot.outputs.value }}\\]" -n 1 --format=%H | grep -q .; then
|
||||
echo "already_ran=true" >>"$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Why: this preserves dedupe across the older scheduled workflow's
|
||||
# first runs, before all RC cuts shared release-cut's slot marker.
|
||||
latest_rc_tag="$(git for-each-ref --sort=-creatordate --format='%(refname:short) %(creatordate:iso-strict)' 'refs/tags/v*-rc.*' | head -n 1)"
|
||||
if [[ -n "$latest_rc_tag" ]]; then
|
||||
latest_rc_tag_name="${latest_rc_tag%% *}"
|
||||
latest_rc_tag_date="${latest_rc_tag#* }"
|
||||
latest_rc_slot="$(TZ=America/Los_Angeles date -d "$latest_rc_tag_date" '+%Y-%m-%d-%H')"
|
||||
|
||||
if [[ "$latest_rc_slot" == "${{ steps.slot.outputs.value }}" ]]; then
|
||||
echo "already_ran=true" >>"$GITHUB_OUTPUT"
|
||||
echo "reason=latest_rc_tag:$latest_rc_tag_name" >>"$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "already_ran=false" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Dry run summary
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dry_run
|
||||
run: |
|
||||
echo "Dry run only."
|
||||
echo "Current PT slot: ${{ steps.slot.outputs.value }}"
|
||||
echo "Window allowed: ${{ steps.window.outputs.allowed }}"
|
||||
echo "Window reason: ${{ steps.window.outputs.reason }}"
|
||||
echo "Already ran this slot: ${{ steps.existing.outputs.already_ran }}"
|
||||
echo "Reason: ${{ steps.existing.outputs.reason }}"
|
||||
|
||||
- name: Skip summary
|
||||
if: steps.window.outputs.allowed != 'true' || steps.existing.outputs.already_ran == 'true'
|
||||
run: |
|
||||
echo "Skipping release cut."
|
||||
echo "Current PT slot: ${{ steps.slot.outputs.value }}"
|
||||
echo "Window reason: ${{ steps.window.outputs.reason }}"
|
||||
echo "Already ran this slot: ${{ steps.existing.outputs.already_ran }}"
|
||||
echo "Reason: ${{ steps.existing.outputs.reason }}"
|
||||
|
||||
- name: Compute next version
|
||||
id: version
|
||||
if: steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
KIND: ${{ inputs.kind }}
|
||||
KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Latest stable release tag, picked by *tag shape* (no `-rc.`)
|
||||
# rather than the GitHub `isPrerelease` flag. electron-builder's
|
||||
# publish step has flipped that flag back to `false` on RC
|
||||
# releases in the past (issue surfaced 2026-04-27 when
|
||||
# v1.3.22-rc.2 became "latest" on GitHub and poisoned the
|
||||
# compute-next-version math here). Filtering by tag format is
|
||||
# the authoritative signal — an `-rc.N` tag is never stable,
|
||||
# regardless of what the release metadata says.
|
||||
# Latest stable release tag, picked by *tag shape*:
|
||||
# - must start with `v<digit>` (desktop convention, e.g. v1.3.32)
|
||||
# - must NOT contain `-rc.` (not a prerelease)
|
||||
#
|
||||
# Why not the GitHub `isPrerelease` flag: electron-builder's publish
|
||||
# step has flipped that flag back to `false` on RC releases before
|
||||
# (v1.3.22-rc.2 on 2026-04-27 briefly became "latest" on GitHub and
|
||||
# poisoned the math here). Tag format is authoritative.
|
||||
#
|
||||
# Why the `^v[0-9]` prefix: other products shipped from this repo
|
||||
# use their own prefixes (e.g. `mobile-v0.0.1`). Without the prefix
|
||||
# gate the latest mobile release would be selected as "latest
|
||||
# stable", then `strip_pre()` would reduce `mobile-v0.0.1` to
|
||||
# `mobile`, `Number("mobile")` → NaN → 0, and a patch-bump would
|
||||
# produce `0.0.1` — exactly the wedge on 2026-05-04 (run
|
||||
# 25304336767). Any non-desktop tag shape must be excluded here.
|
||||
latest_stable="$(gh release list \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--exclude-drafts \
|
||||
--limit 50 \
|
||||
--json tagName \
|
||||
--jq '[.[] | .tagName | select(test("-rc\\.") | not)] | .[0] // ""')"
|
||||
--jq '[.[] | .tagName | select(test("^v[0-9]") and (test("-rc\\.") | not))] | .[0] // ""')"
|
||||
latest_stable="${latest_stable#v}"
|
||||
echo "Latest stable: ${latest_stable:-<none>}"
|
||||
|
||||
@@ -187,10 +286,53 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
|
||||
# Refuse tag collisions (possible if someone already tagged manually).
|
||||
# Orphan-tag recovery.
|
||||
#
|
||||
# Why: if a previous cut pushed the tag but was cancelled (or the
|
||||
# dependent release.yml job otherwise failed to start) before the
|
||||
# GitHub Release was published, the tag now exists on the remote
|
||||
# but "latest stable" still points at the prior version. Every
|
||||
# subsequent patch cut then recomputes the same version and dies
|
||||
# on "Tag already exists." This exact sequence wedged the cut
|
||||
# pipeline on 2026-05-01 when v1.3.26 was pushed by a cancelled
|
||||
# run (25237882049) — every patch cut after that rehit the same
|
||||
# tag for hours until the orphan release was dispatched by hand.
|
||||
#
|
||||
# Recovery policy: if the tag exists AND no GitHub release has
|
||||
# been published for it (draft-or-absent both count as "not
|
||||
# shipped"), treat this as a resumable state: emit the existing
|
||||
# tag as the job output so the downstream release.yml job runs
|
||||
# against it and finishes what the earlier attempt started. The
|
||||
# bump/commit/push steps are skipped in that case — there is
|
||||
# nothing to bump; the tag is already on the remote.
|
||||
#
|
||||
# Refuse collisions only when the tag *and* a published release
|
||||
# already exist — that's a real conflict (someone tagged manually
|
||||
# over a shipped version) and needs human attention.
|
||||
if git rev-parse "v$new" >/dev/null 2>&1; then
|
||||
echo "::error::Tag v$new already exists." >&2
|
||||
exit 1
|
||||
# Use the REST API, not `gh release view`: the latter returns
|
||||
# "release not found" for drafts *and* for absent releases,
|
||||
# which would cause us to "recover" over a draft that a human
|
||||
# is preparing. The API distinguishes them.
|
||||
release_state="$(gh api \
|
||||
"repos/$GITHUB_REPOSITORY/releases/tags/v$new" \
|
||||
--jq '.draft' 2>/dev/null || echo "missing")"
|
||||
case "$release_state" in
|
||||
missing|true)
|
||||
echo "::warning::Tag v$new already exists but no published release is attached — recovering by re-dispatching release.yml against the existing tag."
|
||||
echo "recovered_tag=v$new" >>"$GITHUB_OUTPUT"
|
||||
echo "recovered=true" >>"$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
;;
|
||||
false)
|
||||
echo "::error::Tag v$new already exists and has a published release. Refusing to re-cut over a shipped version." >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unexpected release state for v$new: $release_state" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo "version=$new" >>"$GITHUB_OUTPUT"
|
||||
@@ -198,7 +340,10 @@ jobs:
|
||||
|
||||
- name: Bump package.json and tag
|
||||
id: tag
|
||||
if: steps.version.outputs.version != '' && steps.version.outputs.recovered != 'true'
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
SLOT: ${{ steps.slot.outputs.value }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -207,12 +352,17 @@ jobs:
|
||||
# assumptions and any lifecycle scripts that would run on bump).
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
git add package.json
|
||||
git commit -m "release: v$VERSION"
|
||||
commit_message="release: v$VERSION"
|
||||
if [[ "$EVENT_NAME" == "schedule" ]]; then
|
||||
commit_message="$commit_message [rc-slot:$SLOT]"
|
||||
fi
|
||||
git commit -m "$commit_message"
|
||||
git tag -a "v$VERSION" -m "v$VERSION"
|
||||
echo "tag=v$VERSION" >>"$GITHUB_OUTPUT"
|
||||
echo "sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Push tag
|
||||
if: steps.tag.outputs.tag != ''
|
||||
env:
|
||||
PUSH_MAIN: ${{ steps.resolve.outputs.push_main }}
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
@@ -227,9 +377,285 @@ jobs:
|
||||
git push origin "$TAG"
|
||||
fi
|
||||
|
||||
release:
|
||||
create-release:
|
||||
needs: cut
|
||||
uses: ./.github/workflows/release.yml
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Create draft release with auto-generated notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
run: |
|
||||
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "Release $TAG already exists."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
is_rc=false
|
||||
if [[ "$TAG" == *"-rc."* ]]; then
|
||||
is_rc=true
|
||||
fi
|
||||
|
||||
gh release create "$TAG" \
|
||||
--draft \
|
||||
--generate-notes \
|
||||
--prerelease="$is_rc"
|
||||
|
||||
# Why: E2E runs alongside the release for visibility (failures surface as a
|
||||
# red check on the tag), but is NOT in `publish-release`'s needs list.
|
||||
# Releases already take a while and the suite is already a required check
|
||||
# on PRs, so gating here would mostly delay shipping without adding signal.
|
||||
e2e:
|
||||
needs: cut
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
build:
|
||||
needs:
|
||||
- cut
|
||||
- create-release
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-15
|
||||
platform: mac
|
||||
release_command: ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
|
||||
eb_cache_path: |
|
||||
~/Library/Caches/electron
|
||||
~/Library/Caches/electron-builder
|
||||
- os: windows-latest
|
||||
platform: win
|
||||
release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish always
|
||||
eb_cache_path: |
|
||||
~\AppData\Local\electron\Cache
|
||||
~\AppData\Local\electron-builder\Cache
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux --publish always
|
||||
eb_cache_path: |
|
||||
~/.cache/electron
|
||||
~/.cache/electron-builder
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
# pnpm must be on PATH before setup-node so setup-node can locate the store for caching.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Why: release builds hit the same native-module postinstall path as
|
||||
# PR CI, so keep the pinned node-gyp override here too instead of
|
||||
# relying on pnpm's bundled copy. Scoped to Linux via runner.os (not
|
||||
# a specific matrix image) because the failing postinstall has only
|
||||
# been observed on Linux runners — see run 25081763129. The macOS
|
||||
# and Windows release jobs exercise the same pnpm install path and
|
||||
# have not reproduced it, so keep the gate narrow until we know why.
|
||||
# Using runner.os instead of matrix.os == 'ubuntu-latest' means the
|
||||
# gate still works if another Linux matrix entry is added later.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
# Cache the Electron binary + electron-builder tool downloads (notarytool,
|
||||
# winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac.
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ matrix.eb_cache_path }}
|
||||
key: electron-builder-${{ matrix.platform }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
electron-builder-${{ matrix.platform }}-
|
||||
|
||||
# Why: pnpm install triggers electron's postinstall, which downloads the
|
||||
# Electron binary from GitHub release assets. GitHub's download CDN
|
||||
# occasionally returns 504s that fail the whole release. Retry on
|
||||
# failure so transient network errors don't require a manual re-run.
|
||||
- name: Install dependencies
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Verify macOS signing environment
|
||||
if: matrix.platform == 'mac'
|
||||
run: node config/scripts/verify-macos-release-env.mjs
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.MAC_CERTS }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
# Why: telemetry's transport gate (`src/main/telemetry/client.ts:IS_OFFICIAL_BUILD`)
|
||||
# requires the build identity to be the literal string `stable` or `rc`,
|
||||
# substituted by electron-vite's `define` block at build time. Derive
|
||||
# that identity from the release tag here — `stable` for plain semver
|
||||
# (`vX.Y.Z`), `rc` for prerelease (`vX.Y.Z-rc.N`). The strict regex is
|
||||
# a safety net: this workflow only fires on cut-tags that already match
|
||||
# one of those shapes, but if a future change ever loosens that, we
|
||||
# refuse to ship rather than let an unclassified build go out with
|
||||
# `BUILD_IDENTITY = null`.
|
||||
- name: Classify release tag for telemetry build identity
|
||||
id: tag-classify
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
identity=rc
|
||||
elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
identity=stable
|
||||
else
|
||||
echo "::error::Tag $TAG does not match stable or rc pattern; refusing to build official artifact"
|
||||
exit 1
|
||||
fi
|
||||
echo "identity=$identity" >>"$GITHUB_OUTPUT"
|
||||
echo "Classified $TAG as $identity"
|
||||
|
||||
# Why ORCA_POSTHOG_WRITE_KEY here: this is the only build that
|
||||
# produces a published binary, so this is the only place the secret
|
||||
# needs to be in scope. The key is a PostHog *project* API key, not
|
||||
# a server secret — it ships in every official binary's app.asar
|
||||
# and is therefore extractable from any release. We still keep it
|
||||
# in GitHub Actions secrets so the literal stays out of the repo
|
||||
# (and out of fork CI runs / log scrapers / casual greps).
|
||||
# Why ORCA_BUILD_IDENTITY here (not in env at the job level): the
|
||||
# value comes from the per-tag classification above and electron-vite
|
||||
# reads it from `process.env` during `pnpm build:release` only.
|
||||
- name: Build app
|
||||
run: pnpm build:release
|
||||
env:
|
||||
ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }}
|
||||
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
|
||||
|
||||
# Why: macOS signing secrets (CSC_LINK, CSC_KEY_PASSWORD) must NOT be
|
||||
# passed to non-macOS builds. electron-builder uses CSC_LINK as the
|
||||
# code-signing certificate on any platform, so leaking the Apple
|
||||
# Developer ID cert to the Windows build causes the NSIS installer to
|
||||
# be signed with an Apple cert whose chain Windows cannot validate,
|
||||
# breaking the auto-updater with "certificate chain could not be built
|
||||
# to a trusted root authority" (issue #631).
|
||||
#
|
||||
# Why retry: electron-builder downloads NSIS/winCodeSign/squirrel
|
||||
# binaries and the Electron runtime from GitHub release assets during
|
||||
# publish. GitHub's download CDN occasionally returns 504s that fail
|
||||
# the whole release. Retry on failure so transient network errors
|
||||
# don't require a manual re-run.
|
||||
- name: Publish release artifacts (macOS)
|
||||
if: matrix.platform == 'mac'
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: ${{ matrix.release_command }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTS }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Publish release artifacts
|
||||
if: matrix.platform != 'mac'
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: ${{ matrix.release_command }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Why post-publish (not pre-publish): electron-builder packs and
|
||||
# uploads in a single `--publish always` invocation, so there is no
|
||||
# cheap insertion point between pack and upload without splitting
|
||||
# that step. Running verify last still blocks the bad release: the
|
||||
# binary is uploaded to the draft, but a failed matrix job blocks
|
||||
# the `publish-release` job (which depends on `build`) from flipping
|
||||
# the release from draft → published, so users never see it. A human
|
||||
# then deletes the draft and re-cuts.
|
||||
#
|
||||
# Why this guards against: a misconfigured CI run where
|
||||
# `ORCA_POSTHOG_WRITE_KEY` is unset or the tag fails to classify
|
||||
# would otherwise produce a binary with `BUILD_IDENTITY = null` and
|
||||
# `WRITE_KEY = null`, which silently disables transport
|
||||
# (`IS_OFFICIAL_BUILD === false`) — the exact failure mode flagged
|
||||
# in PR #1385's deferred follow-up.
|
||||
- name: Verify telemetry constants present in app.asar
|
||||
run: node config/scripts/verify-telemetry-constants.mjs
|
||||
|
||||
publish-release:
|
||||
needs:
|
||||
- cut
|
||||
- build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Publish release
|
||||
# Why: derive `--prerelease` from the tag shape (not from whatever
|
||||
# electron-builder left the release flagged as). On 2026-04-27,
|
||||
# electron-builder's publish step flipped `prerelease` back to
|
||||
# `false` on -rc.N releases, which caused an RC to be marked as
|
||||
# GitHub's "latest" release and broke release-cut.yml's math.
|
||||
# Re-asserting here means the final release state is determined
|
||||
# by the tag — a ground truth electron-builder can't rewrite.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$TAG" == *"-rc."* ]]; then
|
||||
prerelease=true
|
||||
else
|
||||
prerelease=false
|
||||
fi
|
||||
gh release edit "$TAG" \
|
||||
--draft=false \
|
||||
--prerelease="$prerelease" \
|
||||
--repo "$GITHUB_REPOSITORY"
|
||||
|
||||
homebrew-bump:
|
||||
needs:
|
||||
- cut
|
||||
- publish-release
|
||||
if: ${{ !contains(needs.cut.outputs.tag, '-rc.') }}
|
||||
uses: ./.github/workflows/homebrew-bump.yml
|
||||
with:
|
||||
tag: ${{ needs.cut.outputs.tag }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
name: Schedule RC Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Validate the workflow without creating an RC version or tag
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
schedule:
|
||||
# Why: GitHub scheduled workflows are not guaranteed to start exactly on
|
||||
# time and can be delayed or dropped around high-load periods. We retry
|
||||
# across the full target hour and dedupe per PT slot.
|
||||
- cron: '*/5 3,15 * * *'
|
||||
timezone: 'America/Los_Angeles'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-rc
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
create-rc-tag:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
should_release: ${{ steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Configure git author
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Sync with latest main
|
||||
run: |
|
||||
git fetch origin main --tags
|
||||
git checkout main
|
||||
git reset --hard origin/main
|
||||
|
||||
- name: Compute RC slot
|
||||
id: slot
|
||||
run: |
|
||||
slot=$(TZ=America/Los_Angeles date '+%Y-%m-%d-%H')
|
||||
echo "value=$slot" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate PT release window
|
||||
id: window
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
|
||||
echo "allowed=true" >>"$GITHUB_OUTPUT"
|
||||
echo "reason=manual" >>"$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
pt_hour=$(TZ=America/Los_Angeles date '+%H')
|
||||
pt_minute=$(TZ=America/Los_Angeles date '+%M')
|
||||
|
||||
# Why: GitHub may deliver a scheduled event long after the intended
|
||||
# time. We only allow scheduled RC releases during the exact target
|
||||
# PT hours so a delayed run at 4:16 AM never creates a release that
|
||||
# was intended for 3:00 AM.
|
||||
if [[ "$pt_hour" == "03" || "$pt_hour" == "15" ]]; then
|
||||
echo "allowed=true" >>"$GITHUB_OUTPUT"
|
||||
echo "reason=target_hour:${pt_hour}:${pt_minute}" >>"$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "allowed=false" >>"$GITHUB_OUTPUT"
|
||||
echo "reason=outside_target_hour:${pt_hour}:${pt_minute}" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Skip if this PT release window already ran
|
||||
id: existing
|
||||
if: steps.window.outputs.allowed == 'true'
|
||||
run: |
|
||||
# Why: GitHub can delay or drop schedule events, especially near the
|
||||
# start of the hour. We schedule multiple attempts inside each target
|
||||
# PT hour and make the release idempotent per hour by embedding a
|
||||
# slot marker in the release commit message.
|
||||
if git log origin/main --grep="\\[rc-slot:${{ steps.slot.outputs.value }}\\]" -n 1 --format=%H | grep -q .; then
|
||||
echo "already_ran=true" >>"$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Why: the first scheduled run today used the earlier workflow before
|
||||
# slot markers existed. Keep a tag-time fallback so retries in the
|
||||
# same PT hour still skip if an RC tag was already created.
|
||||
latest_rc_tag="$(git for-each-ref --sort=-creatordate --format='%(refname:short) %(creatordate:iso-strict)' 'refs/tags/v*-rc.*' | head -n 1)"
|
||||
if [[ -n "$latest_rc_tag" ]]; then
|
||||
latest_rc_tag_name="${latest_rc_tag%% *}"
|
||||
latest_rc_tag_date="${latest_rc_tag#* }"
|
||||
latest_rc_slot="$(TZ=America/Los_Angeles date -d "$latest_rc_tag_date" '+%Y-%m-%d-%H')"
|
||||
|
||||
if [[ "$latest_rc_slot" == "${{ steps.slot.outputs.value }}" ]]; then
|
||||
echo "already_ran=true" >>"$GITHUB_OUTPUT"
|
||||
echo "reason=latest_rc_tag:$latest_rc_tag_name" >>"$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "already_ran=false" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Dry run summary
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dry_run
|
||||
run: |
|
||||
echo "Dry run only."
|
||||
echo "Current PT slot: ${{ steps.slot.outputs.value }}"
|
||||
echo "Window allowed: ${{ steps.window.outputs.allowed }}"
|
||||
echo "Window reason: ${{ steps.window.outputs.reason }}"
|
||||
echo "Already ran this slot: ${{ steps.existing.outputs.already_ran }}"
|
||||
echo "Reason: ${{ steps.existing.outputs.reason }}"
|
||||
|
||||
- name: Skip summary
|
||||
if: steps.window.outputs.allowed != 'true'
|
||||
run: |
|
||||
echo "Skipping scheduled RC release."
|
||||
echo "Current PT slot: ${{ steps.slot.outputs.value }}"
|
||||
echo "Reason: ${{ steps.window.outputs.reason }}"
|
||||
|
||||
- name: Create and push RC tag
|
||||
id: version
|
||||
if: steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Why: pushes made with GITHUB_TOKEN do not reliably trigger other
|
||||
# workflows via push events. The RC workflow invokes release.yml
|
||||
# directly after creating the tag, so the actual Electron release
|
||||
# runs inside this scheduled flow instead of depending on push.
|
||||
new_tag="$(npm version prerelease --preid=rc -m "release: %s [rc-slot:${{ steps.slot.outputs.value }}]")"
|
||||
git push origin main --follow-tags
|
||||
echo "tag=$new_tag" >>"$GITHUB_OUTPUT"
|
||||
|
||||
release-rc:
|
||||
needs: create-rc-tag
|
||||
if: needs.create-rc-tag.outputs.should_release == 'true'
|
||||
uses: ./.github/workflows/release.yml
|
||||
with:
|
||||
tag: ${{ needs.create-rc-tag.outputs.tag }}
|
||||
secrets: inherit
|
||||
@@ -1,232 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: Tag to release, for example v1.2.3-rc.0
|
||||
required: true
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Existing tag to release, for example v1.2.3-rc.0
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
resolve-release:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag: ${{ steps.resolve.outputs.tag }}
|
||||
ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- name: Resolve release tag and ref
|
||||
id: resolve
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "push" ]]; then
|
||||
release_tag="${{ github.ref_name }}"
|
||||
release_ref="${{ github.ref }}"
|
||||
else
|
||||
release_tag="${{ inputs.tag }}"
|
||||
release_ref="refs/tags/${{ inputs.tag }}"
|
||||
fi
|
||||
|
||||
echo "tag=$release_tag" >>"$GITHUB_OUTPUT"
|
||||
echo "ref=$release_ref" >>"$GITHUB_OUTPUT"
|
||||
|
||||
create-release:
|
||||
needs: resolve-release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.resolve-release.outputs.ref }}
|
||||
|
||||
- name: Create draft release with auto-generated notes
|
||||
run: |
|
||||
if gh release view "${{ needs.resolve-release.outputs.tag }}" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
echo "Release ${{ needs.resolve-release.outputs.tag }} already exists."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh release create "${{ needs.resolve-release.outputs.tag }}" \
|
||||
--draft \
|
||||
--generate-notes \
|
||||
--prerelease=${{ contains(needs.resolve-release.outputs.tag, 'rc') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Why: E2E runs alongside the release for visibility (failures surface as a
|
||||
# red check on the tag), but it is NOT in `release`'s needs list. Releases
|
||||
# already take a while and the suite is already a required check on PRs, so
|
||||
# gating here would mostly just delay shipping without adding much signal.
|
||||
# Matches the pattern used by noqa's app deploy, which runs E2E with
|
||||
# continue-on-error so failures are visible but don't block the deploy.
|
||||
e2e:
|
||||
needs: resolve-release
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
with:
|
||||
ref: ${{ needs.resolve-release.outputs.ref }}
|
||||
|
||||
release:
|
||||
needs:
|
||||
- resolve-release
|
||||
- create-release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-15
|
||||
platform: mac
|
||||
release_command: ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
|
||||
eb_cache_path: |
|
||||
~/Library/Caches/electron
|
||||
~/Library/Caches/electron-builder
|
||||
- os: windows-latest
|
||||
platform: win
|
||||
release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish always
|
||||
eb_cache_path: |
|
||||
~\AppData\Local\electron\Cache
|
||||
~\AppData\Local\electron-builder\Cache
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux --publish always
|
||||
eb_cache_path: |
|
||||
~/.cache/electron
|
||||
~/.cache/electron-builder
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.resolve-release.outputs.ref }}
|
||||
|
||||
# pnpm must be on PATH before setup-node so setup-node can locate the store for caching.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
|
||||
# Cache the Electron binary + electron-builder tool downloads (notarytool,
|
||||
# winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac.
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ matrix.eb_cache_path }}
|
||||
key: electron-builder-${{ matrix.platform }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
electron-builder-${{ matrix.platform }}-
|
||||
|
||||
# Why: pnpm install triggers electron's postinstall, which downloads the
|
||||
# Electron binary from GitHub release assets. GitHub's download CDN
|
||||
# occasionally returns 504s that fail the whole release. Retry on
|
||||
# failure so transient network errors don't require a manual re-run.
|
||||
- name: Install dependencies
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Verify macOS signing environment
|
||||
if: matrix.platform == 'mac'
|
||||
run: node config/scripts/verify-macos-release-env.mjs
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.MAC_CERTS }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Build app
|
||||
run: pnpm build:release
|
||||
|
||||
# Why: macOS signing secrets (CSC_LINK, CSC_KEY_PASSWORD) must NOT be
|
||||
# passed to non-macOS builds. electron-builder uses CSC_LINK as the
|
||||
# code-signing certificate on any platform, so leaking the Apple
|
||||
# Developer ID cert to the Windows build causes the NSIS installer to
|
||||
# be signed with an Apple cert whose chain Windows cannot validate,
|
||||
# breaking the auto-updater with "certificate chain could not be built
|
||||
# to a trusted root authority" (issue #631).
|
||||
#
|
||||
# Why retry: electron-builder downloads NSIS/winCodeSign/squirrel
|
||||
# binaries and the Electron runtime from GitHub release assets during
|
||||
# publish. GitHub's download CDN occasionally returns 504s that fail
|
||||
# the whole release. Retry on failure so transient network errors
|
||||
# don't require a manual re-run.
|
||||
- name: Publish release artifacts (macOS)
|
||||
if: matrix.platform == 'mac'
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: ${{ matrix.release_command }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTS }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Publish release artifacts
|
||||
if: matrix.platform != 'mac'
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: ${{ matrix.release_command }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
publish-release:
|
||||
needs:
|
||||
- resolve-release
|
||||
- release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Publish release
|
||||
# Why: derive `--prerelease` from the tag shape (not from whatever
|
||||
# electron-builder left the release flagged as). On 2026-04-27,
|
||||
# electron-builder's publish step flipped `prerelease` back to
|
||||
# `false` on -rc.N releases, which caused an RC to be marked as
|
||||
# GitHub's "latest" release and broke release-cut.yml's math.
|
||||
# Re-asserting here means the final release state is determined
|
||||
# by the tag — a ground truth electron-builder can't rewrite.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.resolve-release.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$TAG" == *"-rc."* ]]; then
|
||||
prerelease=true
|
||||
else
|
||||
prerelease=false
|
||||
fi
|
||||
gh release edit "$TAG" \
|
||||
--draft=false \
|
||||
--prerelease="$prerelease" \
|
||||
--repo "$GITHUB_REPOSITORY"
|
||||
@@ -13,6 +13,7 @@ src/**/*.d.ts
|
||||
!src/preload/index.d.ts
|
||||
!src/renderer/src/env.d.ts
|
||||
!src/renderer/src/mermaid.d.ts
|
||||
!src/types/build-constants.d.ts
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
@@ -80,3 +81,8 @@ docs/design-*.md
|
||||
# Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Agent skill installations (machine-local, populated by agent tooling)
|
||||
/.claude/skills/
|
||||
/.agents/skills/
|
||||
/skills-lock.json
|
||||
|
||||
@@ -22,6 +22,10 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
|
||||
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
|
||||
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
|
||||
|
||||
## SSH Use Case
|
||||
|
||||
All changes must consider the SSH use case. Don't assume local-only execution.
|
||||
|
||||
## GitHub CLI Usage
|
||||
|
||||
Be mindful of the user's `gh` CLI API rate limit — batch requests where possible and avoid unnecessary calls. All code, commands, and scripts must be compatible with macOS, Linux, and Windows.
|
||||
|
||||
@@ -1,24 +1 @@
|
||||
# CLAUDE.md
|
||||
|
||||
See also: [AGENTS.md](./AGENTS.md) for agent-specific rules.
|
||||
|
||||
## Code Comments: Document the "Why"
|
||||
|
||||
When implementing behavior driven by a design doc, spec, or non-obvious constraint, **add a comment explaining why** the code does what it does — not just what it does. This is especially important for:
|
||||
|
||||
- **Safety constraints** — e.g., suppressing an action because it could silently erase a signal, re-create a conflict, or mislead the user.
|
||||
- **Fallback/error-handling choices** — e.g., defaulting to `'modified'` on fs error because it's the least misleading option.
|
||||
- **Architectural boundaries** — e.g., why state lives in the renderer and never crosses IPC, or why a feature belongs to Source Control and not Checks.
|
||||
- **Compatibility shims** — e.g., when a field exists purely for downstream plumbing and does not carry semantic meaning.
|
||||
- **Intentional omissions** — e.g., skipping submodule conflicts or not providing rename metadata because the data source doesn't support it.
|
||||
|
||||
A future maintainer who hasn't read the design doc should be able to understand from the comment alone why the code must not be changed casually.
|
||||
|
||||
## Package Manager
|
||||
|
||||
This project uses **pnpm**. Never use `npm` or `yarn`.
|
||||
|
||||
- Use `pnpm install` (not `npm install`)
|
||||
- Use `pnpm add <pkg>` (not `npm install <pkg>`)
|
||||
- Use `pnpm run <script>` (not `npm run <script>`)
|
||||
- The lock file is `pnpm-lock.yaml`. Do not generate `package-lock.json` or `yarn.lock`.
|
||||
@AGENTS.md
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
cask "orca" do
|
||||
arch arm: "arm64", intel: "x64"
|
||||
|
||||
version "1.3.24"
|
||||
sha256 arm: "fc707f290ff3b631b7b7947bf339885b61a43d2e89475997c125b61268ed4966",
|
||||
intel: "5f677c13a08f7a5740442e29d388285a86488c8c1f7aa5f10a8721a2c6ede8e4"
|
||||
|
||||
url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg",
|
||||
verified: "github.com/stablyai/orca/"
|
||||
name "Orca"
|
||||
desc "IDE for orchestrating AI coding agents across terminals and worktrees"
|
||||
homepage "https://onorca.dev/"
|
||||
|
||||
livecheck do
|
||||
url :url
|
||||
strategy :github_latest
|
||||
end
|
||||
|
||||
# Why: electron-updater (src/main/updater.ts) handles in-place updates by
|
||||
# writing a new Orca.app into /Applications. Marking the cask auto_updates
|
||||
# tells Homebrew not to compete with the in-app updater — `brew upgrade`
|
||||
# becomes a no-op unless the user passes --greedy, and brew's version
|
||||
# metadata stays aligned with whatever the app has swapped itself to.
|
||||
auto_updates true
|
||||
depends_on macos: ">= :big_sur"
|
||||
|
||||
app "Orca.app"
|
||||
|
||||
# Why: Orca writes user data under ~/.orca (worktrees, agent state) and
|
||||
# Electron's standard userData directories. Zap removes everything the app
|
||||
# creates during normal use so `brew uninstall --zap` is a clean slate.
|
||||
zap trash: [
|
||||
"~/.orca",
|
||||
"~/Library/Application Support/Orca",
|
||||
"~/Library/Caches/com.stablyai.orca",
|
||||
"~/Library/Caches/com.stablyai.orca.ShipIt",
|
||||
"~/Library/HTTPStorages/com.stablyai.orca",
|
||||
"~/Library/Preferences/com.stablyai.orca.plist",
|
||||
"~/Library/Saved Application State/com.stablyai.orca.savedState",
|
||||
]
|
||||
end
|
||||
@@ -1,8 +1,6 @@
|
||||
<p align="center">
|
||||
<a href="https://onOrca.dev"><img src="resources/build/icon.png" alt="Orca" width="128" /></a>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Orca</h1>
|
||||
<h1 align="center">
|
||||
<a href="https://onOrca.dev"><img src="resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge" alt="Supported Platforms" />
|
||||
@@ -21,7 +19,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://onOrca.dev"><strong>Download at onOrca.dev</strong></a>
|
||||
<a href="#install"><strong>Download 🐋</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -30,10 +28,10 @@
|
||||
|
||||
## Supported Agents
|
||||
|
||||
Orca supports any CLI agent (_not just this list_).
|
||||
Orca supports any CLI agent (*not just this list*).
|
||||
|
||||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="https://www.google.com/s2/favicons?domain=anthropic.com&sz=64" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="docs/assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
@@ -42,15 +40,16 @@ Orca supports any CLI agent (_not just this list_).
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=factory.ai&sz=64" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="docs/assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=kilo.ai&sz=64" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-cli/guides/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
@@ -73,8 +72,28 @@ Orca supports any CLI agent (_not just this list_).
|
||||
|
||||
## Install
|
||||
|
||||
### Mac, Linux, Windows
|
||||
|
||||
- **[Download from onOrca.dev](https://onOrca.dev)**
|
||||
- Or download the latest binaries via the **[GitHub Releases page](https://github.com/stablyai/orca/releases)**.
|
||||
- Or via **[GitHub Releases page](https://github.com/stablyai/orca/releases/latest)**
|
||||
|
||||
*Alternatively, install from a package manager:*
|
||||
|
||||
### macOS (Homebrew)
|
||||
|
||||
```bash
|
||||
brew install --cask stablyai/orca/orca
|
||||
```
|
||||
|
||||
### Arch Linux (AUR)
|
||||
|
||||
```bash
|
||||
# Precompiled binary
|
||||
yay -S stably-orca-bin
|
||||
|
||||
# Build from GitHub source
|
||||
yay -S stably-orca-git
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -131,10 +150,11 @@ npx skills add https://github.com/stablyai/orca --skill orca-cli
|
||||
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
|
||||
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
|
||||
- **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
|
||||
- **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
|
||||
- **Show Support:** Star this repo to follow along with our daily ships.
|
||||
|
||||
---
|
||||
|
||||
## Developing
|
||||
|
||||
Want to contribute or run locally? See our [CONTRIBUTING.md](CONTRIBUTING.md) guide.
|
||||
Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTING.md) guide.
|
||||
|
||||
@@ -14,7 +14,7 @@ module.exports = {
|
||||
'!**/.vscode/*',
|
||||
'!src/*',
|
||||
'!electron.vite.config.{js,ts,mjs,cjs}',
|
||||
'!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}',
|
||||
'!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}',
|
||||
'!{.env,.env.*,.npmrc,pnpm-lock.yaml}',
|
||||
'!tsconfig.json',
|
||||
'!config/*'
|
||||
@@ -81,8 +81,21 @@ module.exports = {
|
||||
entitlements: 'resources/build/entitlements.mac.plist',
|
||||
entitlementsInherit: 'resources/build/entitlements.mac.plist',
|
||||
extendInfo: {
|
||||
NSAppleEventsUsageDescription:
|
||||
'Orca allows terminal-launched developer tools to automate local apps when you request it.',
|
||||
NSBluetoothAlwaysUsageDescription:
|
||||
'Orca allows terminal-launched developer tools to access Bluetooth devices when you request it.',
|
||||
NSBluetoothPeripheralUsageDescription:
|
||||
'Orca allows terminal-launched developer tools to access Bluetooth devices when you request it.',
|
||||
NSCameraUsageDescription: "Application requests access to the device's camera.",
|
||||
NSLocationUsageDescription:
|
||||
'Orca allows terminal-launched developer tools to access location when you request it.',
|
||||
NSLocalNetworkUsageDescription:
|
||||
'Orca allows terminal-launched developer tools to discover and connect to local development servers when you request it.',
|
||||
NSMicrophoneUsageDescription: "Application requests access to the device's microphone.",
|
||||
NSAudioCaptureUsageDescription:
|
||||
'Orca allows terminal-launched developer tools to capture desktop audio when you request it.',
|
||||
NSBonjourServices: ['_http._tcp', '_https._tcp'],
|
||||
NSDocumentsFolderUsageDescription:
|
||||
"Application requests access to the user's Documents folder.",
|
||||
NSDownloadsFolderUsageDescription:
|
||||
|
||||
@@ -73,3 +73,18 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088b
|
||||
var DEFAULT_FILE = 'sh';
|
||||
var DEFAULT_NAME = 'xterm';
|
||||
var DESTROY_SOCKET_TIMEOUT_MS = 200;
|
||||
diff --git a/src/unix/pty.cc b/src/unix/pty.cc
|
||||
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..c17decbb7bc06f10d7b5d26317bd68fdc8c4e339 100644
|
||||
--- a/src/unix/pty.cc
|
||||
+++ b/src/unix/pty.cc
|
||||
@@ -778,8 +778,8 @@ done:
|
||||
posix_spawn_file_actions_destroy(&acts);
|
||||
posix_spawnattr_destroy(&attrs);
|
||||
|
||||
- for (; count > 0; count--) {
|
||||
- close(low_fds[count]);
|
||||
+ for (size_t i = 0; i <= count && i < 3; i++) {
|
||||
+ close(low_fds[i]);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -15,7 +15,8 @@ import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = join(__dirname, '..')
|
||||
// Why: the script lives under config/scripts, so go two levels up to reach the repo root.
|
||||
const ROOT = join(__dirname, '..', '..')
|
||||
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
|
||||
|
||||
const PLATFORMS = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64']
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env node
|
||||
// Symlinks the orca-dev wrapper into /usr/local/bin so the dev CLI is
|
||||
// available globally after `pnpm run build:cli`.
|
||||
import { existsSync, lstatSync, readlinkSync } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = path.join(scriptDir, 'orca-dev')
|
||||
|
||||
const commandPath =
|
||||
process.platform === 'darwin' || process.platform === 'linux'
|
||||
? '/usr/local/bin/orca-dev'
|
||||
: null
|
||||
|
||||
if (!commandPath) {
|
||||
console.log('[orca-dev] Skipping global symlink (unsupported platform).')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
function isOwnedByUs(target) {
|
||||
try {
|
||||
if (!lstatSync(target).isSymbolicLink()) { return false }
|
||||
return readlinkSync(target) === source
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(commandPath)) {
|
||||
if (isOwnedByUs(commandPath)) {
|
||||
console.log(`[orca-dev] ${commandPath} already points to dev CLI.`)
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(
|
||||
`[orca-dev] ${commandPath} exists but is not our symlink. Remove it manually if you want the dev CLI installed globally.`
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync('ln', ['-s', source, commandPath], { stdio: 'inherit' })
|
||||
console.log(`[orca-dev] Symlinked ${commandPath} → ${source}`)
|
||||
} catch {
|
||||
console.log(
|
||||
`[orca-dev] Could not create ${commandPath} (permission denied). Run once with:\n` +
|
||||
` sudo ln -s ${source} ${commandPath}`
|
||||
)
|
||||
}
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Dev CLI wrapper — uses a separate userData path to avoid clobbering production Orca data.
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve through symlinks so this works when called via /usr/local/bin/orca-dev
|
||||
SOURCE="$0"
|
||||
while [ -L "$SOURCE" ]; do
|
||||
DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
|
||||
SOURCE="$(readlink "$SOURCE")"
|
||||
[[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE"
|
||||
done
|
||||
SCRIPT_DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
CLI_ENTRY="$REPO_ROOT/out/cli/index.js"
|
||||
|
||||
if [ ! -f "$CLI_ENTRY" ]; then
|
||||
echo "orca-dev: CLI not built yet. Run 'pnpm run build:cli' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin) default_data="$HOME/Library/Application Support/orca-dev" ;;
|
||||
MINGW*|MSYS*) default_data="${APPDATA:-$HOME/AppData/Roaming}/orca-dev" ;;
|
||||
*) default_data="${XDG_CONFIG_HOME:-$HOME/.config}/orca-dev" ;;
|
||||
esac
|
||||
|
||||
export ORCA_USER_DATA_PATH="${ORCA_DEV_USER_DATA_PATH:-$default_data}"
|
||||
exec node "$CLI_ENTRY" "$@"
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Why this script exists:
|
||||
*
|
||||
* The standard `electron-builder install-app-deps` uses @electron/rebuild
|
||||
* internally but does not expose the `ignoreModules` option (as of
|
||||
* electron-builder 26.x). On Windows dev machines that lack the full
|
||||
* Visual C++ / Python build toolchain, `cpu-features@0.0.10` (an optional
|
||||
* performance dependency of `ssh2`) fails to build with node-gyp because
|
||||
* `buildcheck.gypi` is missing from the tarball. This causes the entire
|
||||
* postinstall step to fail and prevents `pnpm install` from completing.
|
||||
*
|
||||
* This script replaces `electron-builder install-app-deps` in the postinstall
|
||||
* lifecycle. It calls @electron/rebuild's JS API directly so that we can pass
|
||||
* `ignoreModules: ['cpu-features']` on Windows. Skipping cpu-features is
|
||||
* safe: ssh2 detects the missing native module and falls back to pure-JS CPU
|
||||
* feature detection automatically.
|
||||
*
|
||||
* On macOS and Linux the full rebuild (including cpu-features) runs as usual.
|
||||
*/
|
||||
|
||||
import { rebuild } from '@electron/rebuild'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const projectDir = process.cwd()
|
||||
const electronVersion = JSON.parse(
|
||||
readFileSync(resolve(projectDir, 'node_modules/electron/package.json'), 'utf8')
|
||||
).version
|
||||
|
||||
const ignoreModules = process.platform === 'win32' ? ['cpu-features'] : []
|
||||
|
||||
if (ignoreModules.length > 0) {
|
||||
console.log(`[rebuild] Skipping modules on Windows: ${ignoreModules.join(', ')}`)
|
||||
}
|
||||
|
||||
// Why: @electron/rebuild's default module walker doesn't reliably find native
|
||||
// modules inside pnpm's .pnpm/ store. Passing an explicit list of modules to
|
||||
// rebuild via `onlyModules` ensures they're recompiled against Electron's Node
|
||||
// ABI regardless of the package manager's store layout.
|
||||
const NATIVE_MODULES = ['better-sqlite3', 'node-pty', 'cpu-features']
|
||||
const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m))
|
||||
|
||||
// Why: cpu-features ships without `buildcheck.gypi`; its own `install` script
|
||||
// generates it by running `node buildcheck.js > buildcheck.gypi` before
|
||||
// node-gyp. @electron/rebuild with `force: true` invokes node-gyp directly
|
||||
// and bypasses that install hook, so if the file is missing (fresh install,
|
||||
// store prune, or a prior failed run) node-gyp aborts with
|
||||
// "buildcheck.gypi not found". Regenerate it here before rebuilding.
|
||||
if (!ignoreModules.includes('cpu-features')) {
|
||||
const cpuFeatureDirs = globSync(
|
||||
'node_modules/.pnpm/cpu-features@*/node_modules/cpu-features',
|
||||
{ cwd: projectDir },
|
||||
)
|
||||
for (const relDir of cpuFeatureDirs) {
|
||||
const dir = resolve(projectDir, relDir)
|
||||
const gypiPath = resolve(dir, 'buildcheck.gypi')
|
||||
if (existsSync(gypiPath)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const out = execFileSync(process.execPath, ['buildcheck.js'], {
|
||||
cwd: dir,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
writeFileSync(gypiPath, out)
|
||||
console.log(`[rebuild] Generated ${relDir}/buildcheck.gypi`)
|
||||
} catch (/** @type {any} */ err) {
|
||||
console.error(
|
||||
`[rebuild] Failed to generate ${relDir}/buildcheck.gypi:`,
|
||||
err?.message ?? err,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await rebuild({
|
||||
buildPath: projectDir,
|
||||
electronVersion,
|
||||
ignoreModules,
|
||||
onlyModules,
|
||||
// Why: without force, @electron/rebuild skips modules it considers
|
||||
// "already built" — even when they were compiled for the wrong ABI
|
||||
// (e.g., system Node instead of Electron's embedded Node). This is
|
||||
// common after pnpm install, which compiles native modules for system
|
||||
// Node before postinstall runs this script.
|
||||
force: true,
|
||||
})
|
||||
} catch (/** @type {any} */ err) {
|
||||
console.error('[rebuild] Native module rebuild failed:', err?.message ?? err)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// Why: Electron-based hosts (e.g. Claude Code, VS Code) set
|
||||
// ELECTRON_RUN_AS_NODE=1 in their terminal environment. If this leaks into
|
||||
@@ -9,6 +11,51 @@ import path from 'node:path'
|
||||
delete process.env.ELECTRON_RUN_AS_NODE
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
|
||||
function getDevUserDataPath() {
|
||||
if (process.env.ORCA_DEV_USER_DATA_PATH) {
|
||||
return process.env.ORCA_DEV_USER_DATA_PATH
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(process.env.HOME ?? '', 'Library', 'Application Support', 'orca-dev')
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Roaming'), 'orca-dev')
|
||||
}
|
||||
return path.join(process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? '', '.config'), 'orca-dev')
|
||||
}
|
||||
|
||||
function prepareDevCliWrapper() {
|
||||
const binDir = path.join(repoRoot, 'out', 'bin')
|
||||
mkdirSync(binDir, { recursive: true })
|
||||
const userDataPath = getDevUserDataPath()
|
||||
const cliPath = path.join(repoRoot, 'out', 'cli', 'index.js')
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(
|
||||
path.join(binDir, 'orca-dev.cmd'),
|
||||
`@echo off\r\nset "ORCA_USER_DATA_PATH=${userDataPath}"\r\nnode "${cliPath}" %*\r\n`,
|
||||
'utf8'
|
||||
)
|
||||
} else {
|
||||
const wrapperPath = path.join(binDir, 'orca-dev')
|
||||
writeFileSync(
|
||||
wrapperPath,
|
||||
`#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexec node ${JSON.stringify(cliPath)} "$@"\n`,
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(wrapperPath, 0o755)
|
||||
}
|
||||
|
||||
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ''}`
|
||||
console.log(`[orca-dev] Prepared wrapper in ${binDir}`)
|
||||
}
|
||||
|
||||
if (process.env.ORCA_SKIP_DEV_CLI_PREPARE !== '1') {
|
||||
prepareDevCliWrapper()
|
||||
}
|
||||
|
||||
// Why: tests inject a tiny fake CLI here so they can verify Ctrl+C tears down
|
||||
// the full child tree without depending on a real electron-vite install.
|
||||
const electronViteCli =
|
||||
@@ -17,6 +64,7 @@ const electronViteCli =
|
||||
const forwardedArgs = ['dev', ...process.argv.slice(2)]
|
||||
const child = spawn(process.execPath, [electronViteCli, ...forwardedArgs], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
// Why: electron-vite launches Electron as a descendant process. Giving the
|
||||
// dev runner its own process group lets Ctrl+C kill the whole tree on macOS
|
||||
// instead of leaving the Electron app alive after the terminal exits.
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Asserts that the telemetry compile-time constants — `ORCA_BUILD_IDENTITY`
|
||||
// and `ORCA_POSTHOG_WRITE_KEY` — were substituted into the shipped binary.
|
||||
// Closes the gap PR #1385 flagged: a release built without those env vars
|
||||
// produces an `IS_OFFICIAL_BUILD === false` binary that ships silently and
|
||||
// transmits nothing. This script runs after `electron-builder` has packed
|
||||
// the app and BEFORE the GitHub release is flipped from draft → published.
|
||||
// A failure here fails the matrix job; the draft release stays in place
|
||||
// until a human resolves it.
|
||||
//
|
||||
// We grep the packed `app.asar`'s `out/main/*.js` files (the same files
|
||||
// that the runtime loads) rather than the unpacked `out/`. asar is a
|
||||
// tar-like archive that doesn't transform contents, so the two are
|
||||
// byte-equivalent today — but verifying the asar protects against any
|
||||
// future config change that excludes the main bundle from the package.
|
||||
//
|
||||
// Forward-compat: while `TELEMETRY_ENABLED` is `false` in the source, the
|
||||
// bundler dead-code-eliminates the entire transport block, so the
|
||||
// `BUILD_IDENTITY = "..."` and `WRITE_KEY = "phc_..."` constants do not
|
||||
// appear in the binary even when env vars are correctly injected. This
|
||||
// script reads the source flag at build time and skips the assertion in
|
||||
// that case — the moment the flag flips to `true` (PR #1385), every
|
||||
// subsequent release is enforced.
|
||||
//
|
||||
// Cross-platform note: written in Node so it runs identically on the Mac,
|
||||
// Linux, and Windows release runners. Locating `app.asar` via `fs.readdir`
|
||||
// (instead of POSIX `find`) avoids depending on Git Bash on Windows.
|
||||
// Reading the asar via the programmatic `@electron/asar` API (instead of
|
||||
// shelling out to `npx asar`) avoids both a network fetch on every run
|
||||
// and the Windows `.cmd`-shim/`shell: true` workaround.
|
||||
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
// Why @electron/asar: canonical replacement for the deprecated `asar` package.
|
||||
// It's transitively available via electron-builder (and pnpm's
|
||||
// `shamefully-hoist=true` in `.npmrc` flattens it into the root
|
||||
// `node_modules`). If electron-builder ever drops it, promote this to a
|
||||
// direct devDependency in package.json.
|
||||
import { extractFile, listPackage } from '@electron/asar'
|
||||
|
||||
// Why resolve from import.meta.url instead of cwd: a release runner (or a
|
||||
// developer debugging locally) may invoke this script from a non-root cwd.
|
||||
// Resolving relative to the script's own location turns a misleading
|
||||
// "could not parse TELEMETRY_ENABLED flag" parse error into a clear
|
||||
// file-not-found error, and decouples the script from the caller's cwd.
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
||||
|
||||
function findAsar(rootDir) {
|
||||
// Why: electron-builder produces one `app.asar` per platform-arch combo.
|
||||
// Linux/Windows targets ship one (`dist/linux-unpacked/resources/app.asar`,
|
||||
// `dist/win-unpacked/resources/app.asar`); macOS dual-arch ships two
|
||||
// (`dist/mac/Orca.app/Contents/Resources/app.asar` for x64,
|
||||
// `dist/mac-arm64/Orca.app/Contents/Resources/app.asar` for arm64) because
|
||||
// `electron-builder.config.cjs` declares `arch: ['x64', 'arm64']`. Both
|
||||
// arches share the same JS bundle through electron-vite's single `main`
|
||||
// build, so the constants are identical across them — but verifying every
|
||||
// match catches the regression where one arch's pack drifts (e.g. a future
|
||||
// arch-specific bundle split that forgets to thread the `define` block).
|
||||
const matches = []
|
||||
const stack = [rootDir]
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop()
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(fullPath)
|
||||
} else if (entry.isFile() && entry.name === 'app.asar') {
|
||||
matches.push(fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
const distDir = process.argv[2] ?? 'dist'
|
||||
if (!existsSync(distDir) || !statSync(distDir).isDirectory()) {
|
||||
console.error(`::error::dist directory not found at ${distDir}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Why: with `TELEMETRY_ENABLED = false` in source, Rollup eliminates the
|
||||
// transport block and the substituted constants vanish from the binary.
|
||||
// Verifying in that state would always fail. Read the flag from source
|
||||
// instead of inferring it from the build, so the gate cannot drift.
|
||||
const clientSrcPath = join(repoRoot, 'src/main/telemetry/client.ts')
|
||||
const clientSrc = readFileSync(clientSrcPath, 'utf8')
|
||||
const enabledMatch = /^const\s+TELEMETRY_ENABLED\s*=\s*(true|false)/m.exec(clientSrc)
|
||||
if (!enabledMatch) {
|
||||
console.error(`::error::could not parse TELEMETRY_ENABLED flag from ${clientSrcPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (enabledMatch[1] === 'false') {
|
||||
console.log(
|
||||
'TELEMETRY_ENABLED is false in source — transport is dead-code-eliminated, ' +
|
||||
'so the BUILD_IDENTITY/WRITE_KEY constants are not expected in the binary. ' +
|
||||
'Skipping asar grep. (Once the flag flips to true, this verify becomes enforcing.)'
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const asarMatches = findAsar(distDir)
|
||||
if (asarMatches.length === 0) {
|
||||
console.error(`::error::could not locate app.asar under ${distDir}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`Found ${asarMatches.length} app.asar payload(s) under ${distDir}:`)
|
||||
for (const m of asarMatches) {
|
||||
console.log(` - ${m}`)
|
||||
}
|
||||
|
||||
// Why these regexes: electron-vite's `define` block substitutes the bare
|
||||
// identifiers `ORCA_BUILD_IDENTITY` and `ORCA_POSTHOG_WRITE_KEY` with their
|
||||
// JSON-stringified values at build time. `src/main/telemetry/client.ts`
|
||||
// then assigns those into module-local consts named `BUILD_IDENTITY` and
|
||||
// `WRITE_KEY`. electron-vite's main config is not minified (Vite default for
|
||||
// Electron main builds), so Rollup emits the substituted constants verbatim
|
||||
// as `const BUILD_IDENTITY = "stable";`. Match that exact emitted shape so a
|
||||
// regression — e.g. the env var unset and the substitution falling back to
|
||||
// literal `null` — fails the grep instead of slipping through as a falsy-
|
||||
// but-stringy value. NOTE: if `build.minify` is ever enabled on the main
|
||||
// bundle, esbuild/terser will rename top-level consts and this regex must
|
||||
// be revisited (or replaced with a value-based assertion).
|
||||
//
|
||||
// WRITE_KEY char class includes `_` and `-` because PostHog project API
|
||||
// keys use URL-safe base64 alphabet beyond `phc_`.
|
||||
const BUILD_IDENTITY_RE = /const\s+BUILD_IDENTITY\s*=\s*"(rc|stable)"/
|
||||
const WRITE_KEY_RE = /const\s+WRITE_KEY\s*=\s*"(phc_[A-Za-z0-9_-]+)"/
|
||||
|
||||
function verifyAsar(asarPath) {
|
||||
console.log(`Verifying ${asarPath}`)
|
||||
|
||||
// Why list-then-extract (not a hardcoded path): future electron-vite
|
||||
// chunking (e.g. `manualChunks`) could move the constants out of
|
||||
// `out/main/index.js` into `out/main/chunks/telemetry-XYZ.js`. Enumerate
|
||||
// every `.js` under `out/main/` and concatenate before grepping so the
|
||||
// verify is resilient to chunking. listPackage builds entries with the
|
||||
// host `path` module, so on Windows runners separators are backslashes
|
||||
// (`\out\main\index.js`); normalize to forward slashes for the filter
|
||||
// and strip the leading separator (of either kind) before passing the
|
||||
// entry to extractFile.
|
||||
const allEntries = listPackage(asarPath)
|
||||
const mainJsEntries = allEntries.filter((p) => {
|
||||
const normalized = p.replace(/\\/g, '/').replace(/^\/+/, '')
|
||||
return normalized.startsWith('out/main/') && normalized.endsWith('.js')
|
||||
})
|
||||
|
||||
if (mainJsEntries.length === 0) {
|
||||
console.error(`::error::no .js files found under out/main/ in ${asarPath}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const indexJs = mainJsEntries
|
||||
.map((entry) => {
|
||||
// extractFile uses the host path module internally; pass the
|
||||
// host-separator path with the leading separator stripped.
|
||||
const internal = entry.replace(/^[\\/]+/, '')
|
||||
return extractFile(asarPath, internal).toString('utf8')
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const buildIdentityMatch = BUILD_IDENTITY_RE.exec(indexJs)
|
||||
const writeKeyMatch = WRITE_KEY_RE.exec(indexJs)
|
||||
|
||||
if (!buildIdentityMatch) {
|
||||
console.error(
|
||||
`::error::BUILD_IDENTITY constant missing or unexpected value in ${asarPath}`
|
||||
)
|
||||
const sample = indexJs.match(/.{0,80}BUILD_IDENTITY.{0,80}/g)?.slice(0, 5) ?? []
|
||||
for (const line of sample) {
|
||||
console.error(` ${line.slice(0, 200)}`)
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (!writeKeyMatch) {
|
||||
console.error(`::error::PostHog WRITE_KEY missing from ${asarPath}`)
|
||||
const sample = indexJs.match(/.{0,80}WRITE_KEY.{0,80}/g)?.slice(0, 5) ?? []
|
||||
for (const line of sample) {
|
||||
console.error(` ${line.slice(0, 200)}`)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return { asarPath, buildIdentity: buildIdentityMatch[1], writeKey: writeKeyMatch[1] }
|
||||
}
|
||||
|
||||
// Why verify every match (not just the first): macOS dual-arch produces one
|
||||
// asar per arch from the same `out/main` bundle, so identical constants are
|
||||
// expected — but if a future change introduces an arch-specific bundle split
|
||||
// (or a per-arch `electron-vite build` invocation) that forgets to thread
|
||||
// the `define` values, only one of the asars would carry the constants and
|
||||
// the broken arch would ship transmitting nothing. Looping is cheap insurance
|
||||
// against that class of regression. We additionally require every asar to
|
||||
// agree on the BUILD_IDENTITY and WRITE_KEY values — a mismatch means the
|
||||
// matrix shipped inconsistent build-identity claims, or (worse) per-arch
|
||||
// PostHog keys that would split events across projects, both of which are
|
||||
// release bugs.
|
||||
const results = []
|
||||
for (const asarPath of asarMatches) {
|
||||
const result = verifyAsar(asarPath)
|
||||
if (!result) {
|
||||
process.exit(1)
|
||||
}
|
||||
results.push(result)
|
||||
}
|
||||
|
||||
const distinctIdentities = new Set(results.map((r) => r.buildIdentity))
|
||||
if (distinctIdentities.size > 1) {
|
||||
console.error(
|
||||
`::error::asars disagree on BUILD_IDENTITY: ${[...distinctIdentities].join(', ')}`
|
||||
)
|
||||
for (const r of results) {
|
||||
console.error(` - ${r.asarPath}: ${r.buildIdentity}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const distinctWriteKeys = new Set(results.map((r) => r.writeKey))
|
||||
if (distinctWriteKeys.size > 1) {
|
||||
console.error(`::error::asars disagree on WRITE_KEY across arches`)
|
||||
for (const r of results) {
|
||||
console.error(` - ${r.asarPath}: ${r.writeKey.slice(0, 8)}... (length=${r.writeKey.length})`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const [first] = results
|
||||
console.log(
|
||||
`Telemetry constants verified across ${results.length} asar(s): ` +
|
||||
`BUILD_IDENTITY="${first.buildIdentity}", ` +
|
||||
`WRITE_KEY="${first.writeKey.slice(0, 8)}..." (length=${first.writeKey.length})`
|
||||
)
|
||||
@@ -5,7 +5,8 @@
|
||||
"../src/main/**/*",
|
||||
"../src/preload/**/*",
|
||||
"../src/shared/**/*",
|
||||
"../src/relay/**/*"
|
||||
"../src/relay/**/*",
|
||||
"../src/types/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
|
||||
@@ -10,6 +10,6 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts']
|
||||
include: ['src/**/*.test.ts', 'src/**/*.test.tsx']
|
||||
}
|
||||
})
|
||||
|
||||
+43
-24
@@ -1,13 +1,11 @@
|
||||
<p align="center">
|
||||
<a href="https://onOrca.dev"><img src="resources/build/icon.png" alt="Orca" width="128" /></a>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Orca</h1>
|
||||
<h1 align="center">
|
||||
<a href="https://onOrca.dev"><img src="../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge" alt="Supported Platforms" />
|
||||
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge" alt="Plataformas compatibles" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="Follow on X" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="Seguir en X" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -16,24 +14,24 @@
|
||||
|
||||
<p align="center">
|
||||
<strong>El orquestador de IA para desarrolladores 100x.</strong><br/>
|
||||
Corre Claude Code, Codex u OpenCode en paralelo a lo largo de varios repos — cada uno en su propio worktree, todos administrados desde un solo lugar.<br/>
|
||||
Ejecuta Claude Code, Codex u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.<br/>
|
||||
Disponible para <strong>macOS, Windows y Linux</strong>.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://onOrca.dev"><strong>Descarga en onOrca.dev</strong></a>
|
||||
<a href="#instalación"><strong>Descargar 🐋</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/file-drag.gif" alt="Captura de Orca" width="800" />
|
||||
<img src="assets/file-drag.gif" alt="Captura de Orca" width="800" />
|
||||
</p>
|
||||
|
||||
## Agentes compatibles
|
||||
|
||||
Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).
|
||||
Orca es compatible con cualquier agente CLI (*no solo los de esta lista*).
|
||||
|
||||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="https://www.google.com/s2/favicons?domain=anthropic.com&sz=64" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
@@ -42,15 +40,16 @@ Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=factory.ai&sz=64" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=kilo.ai&sz=64" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-cli/guides/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
@@ -63,18 +62,38 @@ Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).
|
||||
|
||||
- **Sin login** — Usa tu propia suscripción de Claude Code o Codex.
|
||||
- **Nativo con worktrees** — Cada feature vive en su propio worktree. Nada de stash ni malabares entre ramas. Crea y cambia al instante.
|
||||
- **Terminales multi-agente** — Corre varios agentes de IA en paralelo en pestañas y paneles. Mira de un vistazo cuáles están activos.
|
||||
- **Terminales multi-agente** — Ejecuta varios agentes de IA en paralelo en pestañas y paneles. Mira de un vistazo cuáles están activos.
|
||||
- **Control de versiones integrado** — Revisa los diffs generados por IA, haz ediciones rápidas y haz commit sin salir de Orca.
|
||||
- **Integración con GitHub** — PRs, issues y checks de Actions vinculados automáticamente a cada worktree.
|
||||
- **Soporte SSH** — Conéctate a máquinas remotas y ejecuta agentes en ellas directamente desde Orca.
|
||||
- **Notificaciones** — Entérate cuando un agente termine o necesite tu atención. Marca hilos como no leídos para retomarlos después.
|
||||
- **Soporte SSH** — Conéctate a máquinas remotas y corre agentes en ellas directamente desde Orca.
|
||||
|
||||
---
|
||||
|
||||
## Instalación
|
||||
|
||||
### Mac, Linux, Windows
|
||||
|
||||
- **[Descarga desde onOrca.dev](https://onOrca.dev)**
|
||||
- O descarga los binarios más recientes desde la **[página de GitHub Releases](https://github.com/stablyai/orca/releases)**.
|
||||
- O desde la **[página de GitHub Releases](https://github.com/stablyai/orca/releases/latest)**
|
||||
|
||||
*También puedes instalar desde un gestor de paquetes:*
|
||||
|
||||
### macOS (Homebrew)
|
||||
|
||||
```bash
|
||||
brew install --cask stablyai/orca/orca
|
||||
```
|
||||
|
||||
### Arch Linux (AUR)
|
||||
|
||||
```bash
|
||||
# Binario precompilado
|
||||
yay -S stably-orca-bin
|
||||
|
||||
# Compilar desde el código de GitHub
|
||||
yay -S stably-orca-git
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -85,7 +104,7 @@ Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).
|
||||
Anota cualquier línea de un diff generado por IA con tus comentarios y mándalo de vuelta al agente para que lo corrija. Mantén el ciclo de revisión bien ajustado — sin copiar números de línea, sin cambiar de contexto.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/annotate-ai-diff.gif" alt="Orca Anotar diff de IA — comenta en diffs generados por IA y envía feedback al agente" width="800" />
|
||||
<img src="assets/annotate-ai-diff.gif" alt="Orca Anotar diff de IA — comenta en diffs generados por IA y envía feedback al agente" width="800" />
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -97,19 +116,19 @@ Anota cualquier línea de un diff generado por IA con tus comentarios y mándalo
|
||||
Si usas varias cuentas de Codex para aprovechar el mejor precio en tokens, Orca te permite cambiar entre ellas al instante — sin volver a hacer login, sin tocar archivos de configuración. Elige una cuenta y sigue construyendo.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/codex-account-switcher.gif" alt="Cambio de cuentas de Codex en Orca — alterna entre múltiples cuentas de Codex" width="800" />
|
||||
<img src="assets/codex-account-switcher.gif" alt="Cambio de cuentas de Codex en Orca — alterna entre múltiples cuentas de Codex" width="800" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## [Nuevo] Navegador y modo diseño por worktree
|
||||
## [Nuevo] Navegador por worktree y modo diseño
|
||||
|
||||
**Mira tu app. Haz clic en cualquier elemento. Arrástralo al chat.**
|
||||
**Mira tu app. Haz clic en cualquier elemento. Suéltalo en el chat.**
|
||||
|
||||
Orca trae un navegador integrado dentro de tu worktree. Previsualiza tu app mientras la construyes, y cuando quieras cambia al modo diseño — haz clic en cualquier elemento de UI y cae directo en tu chat con la IA como contexto. Sin capturas, sin copiar selectores. Solo apunta a lo que quieres cambiar y dile al agente qué hacer.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/orca-design-mode.gif" alt="Modo diseño de Orca — haz clic en cualquier elemento de UI y suéltalo en el chat" width="800" />
|
||||
<img src="assets/orca-design-mode.gif" alt="Modo diseño de Orca — haz clic en cualquier elemento de UI y suéltalo en el chat" width="800" />
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -137,4 +156,4 @@ npx skills add https://github.com/stablyai/orca --skill orca-cli
|
||||
|
||||
## Desarrollo
|
||||
|
||||
¿Quieres contribuir o ejecutar Orca localmente? Consulta nuestra guía [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
¿Quieres contribuir o ejecutar Orca localmente? Consulta nuestra guía [CONTRIBUTING.md](../.github/CONTRIBUTING.md).
|
||||
|
||||
+63
-44
@@ -1,14 +1,11 @@
|
||||
<p align="center">
|
||||
<a href="https://onOrca.dev"><img src="resources/build/icon.png" alt="Orca" width="128" /></a>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Orca</h1>
|
||||
<h1 align="center">
|
||||
<a href="https://onOrca.dev"><img src="../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://img.shields.io/github/stars/stablyai/orca?style=for-the-badge&color=black" alt="GitHub stars" /></a>
|
||||
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-black?style=for-the-badge" alt="Supported Platforms" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-Join-black?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="Follow on X" /></a>
|
||||
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge" alt="対応プラットフォーム" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="X でフォロー" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -16,25 +13,25 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>100x 開発者のための AI IDE。</strong><br/>
|
||||
Claude Code、Codex、または OpenCode をリポジトリをまたいで並行して実行 — 各エージェントは独立したワークツリーで実行され、1 つの場所で集中管理されます。<br/>
|
||||
<strong>macOS、Windows、Linux</strong> で利用可能です。
|
||||
<strong>100x ビルダーのための AI オーケストレーター。</strong><br/>
|
||||
Claude Code、Codex、OpenCode をリポジトリをまたいで並行実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。<br/>
|
||||
<strong>macOS、Windows、Linux</strong> で利用できます。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://onOrca.dev"><strong>onOrca.dev でダウンロード</strong></a>
|
||||
<a href="#インストール"><strong>ダウンロード 🐋</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/file-drag.gif" alt="Orca Screenshot" width="800" />
|
||||
<img src="assets/file-drag.gif" alt="Orca Screenshot" width="800" />
|
||||
</p>
|
||||
|
||||
## 対応する AI Agent
|
||||
## 対応するエージェント
|
||||
|
||||
Orca は任意の CLI Agent に対応しています(_このリストに限定されません_)。
|
||||
Orca は任意の CLI エージェントに対応しています(*このリストに限定されません*)。
|
||||
|
||||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="https://www.google.com/s2/favicons?domain=anthropic.com&sz=64" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
@@ -43,15 +40,16 @@ Orca は任意の CLI Agent に対応しています(_このリストに限定
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=factory.ai&sz=64" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=kilo.ai&sz=64" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-cli/guides/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
@@ -63,53 +61,74 @@ Orca は任意の CLI Agent に対応しています(_このリストに限定
|
||||
## 機能
|
||||
|
||||
- **ログイン不要** — お持ちの Claude Code や Codex サブスクリプションをそのまま利用できます。
|
||||
- **ワークツリーネイティブ** — 各機能は独立したワークツリーで開発。スタッシュやブランチの切り替えに悩む必要はありません。瞬時に立ち上げ、切り替えることができます。
|
||||
- **マルチAgentターミナル** — 複数の AI Agent をタブやペインで並行して実行。どのアカウントがアクティブかを一目で確認できます。
|
||||
- **内蔵バージョン管理** — AI が生成したコードDiffを確認し、素早く編集を行い、Orca から離れることなく直接コミットできます。
|
||||
- **GitHub 連携** — PR、Issue、Actions のチェックは、各ワークツリーに自動的にリンクされます。
|
||||
- **通知** — エージェントが作業を完了した際や注意が必要な際にお知らせします。スレッドを未読にして後で確認することも可能です。
|
||||
- **ワークツリーネイティブ** — 各機能は専用のワークツリーで開発できます。スタッシュやブランチ切り替えに悩まず、すぐに作成して切り替えられます。
|
||||
- **マルチエージェントターミナル** — 複数の AI エージェントをタブやペインで並行実行できます。どれがアクティブかを一目で確認できます。
|
||||
- **組み込みソース管理** — AI が生成した Diff を確認し、すばやく編集して、Orca から離れずにコミットできます。
|
||||
- **GitHub 連携** — PR、Issue、Actions チェックが各ワークツリーに自動で紐づきます。
|
||||
- **SSH サポート** — リモートマシンに接続し、Orca から直接エージェントを実行できます。
|
||||
- **通知** — エージェントが完了したときや注意が必要なときに通知します。スレッドを未読にして後で戻ることもできます。
|
||||
|
||||
---
|
||||
|
||||
## インストール
|
||||
|
||||
### Mac, Linux, Windows
|
||||
|
||||
- **[onOrca.dev からダウンロード](https://onOrca.dev)**
|
||||
- または、**[GitHub Releases ページ](https://github.com/stablyai/orca/releases)**から最新のインストーラーをダウンロードしてください。
|
||||
- または **[GitHub Releases ページ](https://github.com/stablyai/orca/releases/latest)** から入手
|
||||
|
||||
*パッケージマネージャーからもインストールできます:*
|
||||
|
||||
### macOS (Homebrew)
|
||||
|
||||
```bash
|
||||
brew install --cask stablyai/orca/orca
|
||||
```
|
||||
|
||||
### Arch Linux (AUR)
|
||||
|
||||
```bash
|
||||
# ビルド済みバイナリ
|
||||
yay -S stably-orca-bin
|
||||
|
||||
# GitHub ソースからビルド
|
||||
yay -S stably-orca-git
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [新機能] AI Diff へのコメント
|
||||
## [新機能] AI Diff への注釈
|
||||
|
||||
**AI が生成した Diff に直接コメントを追加。**
|
||||
**AI が生成した Diff に直接コメントできます。**
|
||||
|
||||
AI が生成した Diff の任意の行にフィードバックを付け、そのままエージェントに返して修正させましょう。レビューのループを素早く回せます — 行番号をコピーする必要も、コンテキストを切り替える必要もありません。
|
||||
AI が生成した Diff の任意の行にフィードバックを付け、そのままエージェントに返して修正させましょう。レビューのループを素早く保てます — 行番号をコピーする必要も、コンテキストを切り替える必要もありません。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/annotate-ai-diff.gif" alt="Orca AI Diff コメント — AI が生成した Diff にコメントしてエージェントにフィードバックを送信" width="800" />
|
||||
<img src="assets/annotate-ai-diff.gif" alt="Orca AI Diff 注釈 — AI が生成した Diff にコメントしてエージェントにフィードバックを送信" width="800" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## [新機能] Codex アカウントのホットスワップ
|
||||
|
||||
**複数の Codex アカウントをお持ちですか?ワンクリックで切り替え可能です。**
|
||||
**複数の Codex アカウントをお持ちですか?ワンクリックで切り替えできます。**
|
||||
|
||||
最適な API クォータを得るために複数の Codex アカウントを使用している場合、Orca では再ログインや設定ファイルなしで、即座にアカウントをホットスワップできます。ワンクリックでアカウントを切り替え可能です。
|
||||
最適なトークン条件を得るために複数の Codex アカウントを使っている場合、Orca なら再ログインも設定ファイルの編集も不要で、すぐにアカウントをホットスワップできます。アカウントを選んで、そのまま開発を続けられます。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/codex-account-switcher.gif" alt="Orca Codex アカウント切り替え — 複数のアカウント間でホットスワップ" width="800" />
|
||||
<img src="assets/codex-account-switcher.gif" alt="Orca Codex アカウント切り替え — 複数の Codex アカウント間でホットスワップ" width="800" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## [新機能] ワークツリーごとのブラウザ & デザインモード
|
||||
## [新機能] ワークツリーごとのブラウザとデザインモード
|
||||
|
||||
**アプリをプレビュー。要素をクリックして。そのままチャットにドロップ。**
|
||||
**アプリを表示。任意の要素をクリック。チャットにそのまま投入。**
|
||||
|
||||
Orca には、ワークツリー内で使える組み込みブラウザがあります。アプリを作りながらプレビューし、デザインモードに切り替えると、任意の UI 要素をクリックするだけで AI チャットにコンテキストとして直接入ります。スクリーンショットもセレクターのコピーも不要です。変更したい箇所を指して、エージェントに指示するだけです。
|
||||
|
||||
Orca には、ワークツリー内にブラウザが組み込まれています。いつでもアプリをプレビューし、デザインモードに切り替えます — 任意の UI 要素をクリックすると、コンテキストとしてそのまま AI チャットに取り込まれます。
|
||||
<p align="center">
|
||||
<img src="docs/assets/orca-design-mode.gif" alt="Orca デザインモード — UI 要素をクリックしてチャットにドロップ" width="800" />
|
||||
<img src="assets/orca-design-mode.gif" alt="Orca デザインモード — 任意の UI 要素をクリックしてチャットに投入" width="800" />
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -118,7 +137,7 @@ Orca には、ワークツリー内にブラウザが組み込まれています
|
||||
|
||||
**ターミナルからのエージェントオーケストレーション。**
|
||||
|
||||
AI Agent に Orca IDE を制御させましょう。AI を使って IDE にプロジェクトを追加したり、ワークツリーを立ち上げたり、ターミナルから AI Agent に現在のワークツリーのコメントを直接更新させることができます。Orca IDE に同梱されています(設定からインストール)。
|
||||
AI エージェントに IDE を制御させましょう。AI を使って IDE にプロジェクトを追加し、ワークツリーを立ち上げ、現在のワークツリーのコメントを進捗チェックポイントとしてターミナルから直接更新できます。Orca IDE に同梱されています(設定からインストール)。
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/stablyai/orca --skill orca-cli
|
||||
@@ -126,15 +145,15 @@ npx skills add https://github.com/stablyai/orca --skill orca-cli
|
||||
|
||||
---
|
||||
|
||||
## コミュニティ & サポート
|
||||
## コミュニティとサポート
|
||||
|
||||
- **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** のコミュニティに参加する。
|
||||
- **Twitter / X:** アップデートやアナウンスについては **[@orca_build](https://x.com/orca_build)** をフォローしてください。
|
||||
- **フィードバック & アイデア:** 私たちは迅速に新機能をリリースします。不足している機能はありますか?[機能リクエストを送信](https://github.com/stablyai/orca/issues) してください。
|
||||
- **サポートを示す:** 毎日のリリースを追うために、このリポジトリにスターをつけてください。
|
||||
- **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** のコミュニティに参加してください。
|
||||
- **Twitter / X:** アップデートやお知らせは **[@orca_build](https://x.com/orca_build)** をフォローしてください。
|
||||
- **フィードバックとアイデア:** 私たちは高速にリリースしています。足りない機能がありますか?[機能リクエストを送信](https://github.com/stablyai/orca/issues) してください。
|
||||
- **応援する:** 毎日のリリースを追うために、このリポジトリにスターを付けてください。
|
||||
|
||||
---
|
||||
|
||||
## 開発について
|
||||
|
||||
貢献したい、またはローカルで実行したいですか? [CONTRIBUTING.md](CONTRIBUTING.md) ガイドをご覧ください。
|
||||
貢献したい、またはローカルで実行したいですか? [CONTRIBUTING.md](../.github/CONTRIBUTING.md) ガイドをご覧ください。
|
||||
|
||||
+62
-43
@@ -1,14 +1,11 @@
|
||||
<p align="center">
|
||||
<a href="https://onOrca.dev"><img src="resources/build/icon.png" alt="Orca" width="128" /></a>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Orca</h1>
|
||||
<h1 align="center">
|
||||
<a href="https://onOrca.dev"><img src="../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://img.shields.io/github/stars/stablyai/orca?style=for-the-badge&color=black" alt="GitHub stars" /></a>
|
||||
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-black?style=for-the-badge" alt="Supported Platforms" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-Join-black?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="Follow on X" /></a>
|
||||
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge" alt="支持的平台" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="在 X 上关注" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -16,25 +13,25 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>专为 100x 开发者打造的 AI IDE。</strong><br/>
|
||||
跨代码库并排运行 Claude Code、Codex 或 OpenCode — 每个都在自己独立的工作区中运行,并在一个地方集中管理。<br/>
|
||||
<strong>面向 100x 构建者的 AI 编排器。</strong><br/>
|
||||
跨仓库并排运行 Claude Code、Codex 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。<br/>
|
||||
支持 <strong>macOS、Windows 和 Linux</strong>。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://onOrca.dev"><strong>在 onOrca.dev 下载</strong></a>
|
||||
<a href="#安装"><strong>下载 🐋</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/file-drag.gif" alt="Orca Screenshot" width="800" />
|
||||
<img src="assets/file-drag.gif" alt="Orca Screenshot" width="800" />
|
||||
</p>
|
||||
|
||||
## 支持的 AI Agents
|
||||
## 支持的智能体
|
||||
|
||||
Orca 支持任何 CLI Agent (_不仅限于以下列表_)。
|
||||
Orca 支持任何 CLI 智能体(*不仅限于以下列表*)。
|
||||
|
||||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="https://www.google.com/s2/favicons?domain=anthropic.com&sz=64" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
@@ -43,15 +40,16 @@ Orca 支持任何 CLI Agent (_不仅限于以下列表_)。
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=factory.ai&sz=64" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=kilo.ai&sz=64" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-cli/guides/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
@@ -62,31 +60,51 @@ Orca 支持任何 CLI Agent (_不仅限于以下列表_)。
|
||||
|
||||
## 特性
|
||||
|
||||
- **免登录** — 直接用你的 Claude Code 或 Codex 订阅。
|
||||
- **原生支持 Worktree** — 每个功能都在自己的工作树中开发。告别频繁的 stashing 或切换分支。瞬间启动并无缝切换。
|
||||
- **多Agent终端** — 在不同标签和面板中并排运行多个 AI Agent。一目了然地查看哪些代理处于活跃状态。
|
||||
- **内置版本控制** — 查看 AI 生成的代码Diff,进行快速修改并直接提交,无需离开 Orca。
|
||||
- **集成 GitHub** — PR、Issues 和 Actions 检查会自动链接到对应的工作区。
|
||||
- **通知系统** — 当代理完成任务或需要关注时及时通知你。支持标记为未读稍后处理。
|
||||
- **SSH 支持** — 连接远程机器,直接在 Orca 中运行远程 Agent。
|
||||
- **无需登录** — 直接使用你自己的 Claude Code 或 Codex 订阅。
|
||||
- **原生 worktree 工作流** — 每个功能都有自己的 worktree。无需 stash,也不用来回切分支。立即创建,快速切换。
|
||||
- **多智能体终端** — 在标签页和面板中并排运行多个 AI 智能体。一眼就能看到哪些正在活跃。
|
||||
- **内置源码管理** — 查看 AI 生成的 diff,快速编辑,并且无需离开 Orca 就能提交。
|
||||
- **GitHub 集成** — PR、issue 和 Actions 检查会自动链接到对应的 worktree。
|
||||
- **SSH 支持** — 连接远程机器,并直接从 Orca 在远程机器上运行智能体。
|
||||
- **通知** — 智能体完成任务或需要关注时及时通知你。可将会话标记为未读,方便稍后返回处理。
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
### Mac, Linux, Windows
|
||||
|
||||
- **[从 onOrca.dev 下载](https://onOrca.dev)**
|
||||
- 或者通过 **[GitHub Releases 页面](https://github.com/stablyai/orca/releases)** 下载最新的安装包。
|
||||
- 或通过 **[GitHub Releases 页面](https://github.com/stablyai/orca/releases/latest)** 获取
|
||||
|
||||
*也可以通过包管理器安装:*
|
||||
|
||||
### macOS (Homebrew)
|
||||
|
||||
```bash
|
||||
brew install --cask stablyai/orca/orca
|
||||
```
|
||||
|
||||
### Arch Linux (AUR)
|
||||
|
||||
```bash
|
||||
# 预编译二进制
|
||||
yay -S stably-orca-bin
|
||||
|
||||
# 从 GitHub 源码构建
|
||||
yay -S stably-orca-git
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [全新] AI Diff 标注
|
||||
## [全新] 标注 AI Diff
|
||||
|
||||
**直接在 AI 生成的 Diff 上添加批注。**
|
||||
**直接在 AI 生成的 diff 上评论。**
|
||||
|
||||
在 AI 生成的 Diff 的任意一行添加你的反馈,然后发回给 Agent 进行修订。保持评审循环高效顺畅 — 无需复制行号,也无需切换上下文。
|
||||
在 AI 生成的 diff 的任意一行添加你的反馈,然后发回给智能体进行修改。让评审循环保持紧凑 — 无需复制行号,也无需切换上下文。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/annotate-ai-diff.gif" alt="Orca AI Diff 标注 — 在 AI 生成的 Diff 上评论并将反馈发送给 Agent" width="800" />
|
||||
<img src="assets/annotate-ai-diff.gif" alt="Orca 标注 AI Diff — 在 AI 生成的 diff 上评论并将反馈发送给智能体" width="800" />
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -95,30 +113,31 @@ Orca 支持任何 CLI Agent (_不仅限于以下列表_)。
|
||||
|
||||
**有多个 Codex 账号?一键切换。**
|
||||
|
||||
如果你使用多个 Codex 账号以获得最佳 API 额度,Orca 让你能在它们之间即时热切换 — 无需重新登录,也无需修改配置文件。只需一键点击切换账号。
|
||||
如果你使用多个 Codex 账号来获得更合适的 token 配额,Orca 可让你在它们之间即时热切换 — 无需重新登录,也无需修改配置文件。选中账号,然后继续构建。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/codex-account-switcher.gif" alt="Orca Codex 账号切换器 — 在多个账号之间热切换" width="800" />
|
||||
<img src="assets/codex-account-switcher.gif" alt="Orca Codex 账号切换器 — 在多个 Codex 账号之间热切换" width="800" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## [全新] 每工作树内置浏览器与设计模式
|
||||
## [全新] 每个 worktree 的浏览器和设计模式
|
||||
|
||||
**预览应用。点击任意元素。直接拖入聊天框。**
|
||||
**查看你的应用。点击任意元素。直接放入聊天。**
|
||||
|
||||
Orca 在你的 worktree 中内置了浏览器。边构建边预览应用,然后切换到设计模式 — 点击任意 UI 元素,它会作为上下文直接进入你的 AI 聊天。无需截图,无需复制选择器。只需指向想修改的地方,然后告诉智能体要做什么。
|
||||
|
||||
Orca 在你的工作树中内置了浏览器。随时预览你的应用,然后切换到设计模式 — 点击任何 UI 元素,它就会直接落入你的 AI 聊天框作为上下文。
|
||||
<p align="center">
|
||||
<img src="docs/assets/orca-design-mode.gif" alt="Orca 设计模式 — 点击任意 UI 元素并放入聊天框" width="800" />
|
||||
<img src="assets/orca-design-mode.gif" alt="Orca 设计模式 — 点击任意 UI 元素并放入聊天" width="800" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## [全新] 隆重推出 Orca CLI
|
||||
## [全新] Orca CLI 介绍
|
||||
|
||||
**在终端中进行代理编排。**
|
||||
**从终端编排智能体。**
|
||||
|
||||
让 AI Agent控制你的Orca IDE。使用 AI 将项目添加到 IDE、启动工作树,并在终端让AI Agent直接更新当前工作树的备注。随 Orca IDE 附带 (在设置中安装)。
|
||||
让你的 AI 智能体控制 IDE。使用 AI 将项目添加到 IDE、创建 worktree,并直接从终端更新当前 worktree 的评论,留下有意义的进度检查点。Orca IDE 随附此功能(在设置中安装)。
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/stablyai/orca --skill orca-cli
|
||||
@@ -130,11 +149,11 @@ npx skills add https://github.com/stablyai/orca --skill orca-cli
|
||||
|
||||
- **Discord:** 加入我们的 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
|
||||
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
|
||||
- **反馈与想法:** 我们发布新功能非常快。需要新功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
|
||||
- **支持我们:** 给本仓库点亮 Star,关注我们的日常更新。
|
||||
- **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
|
||||
- **支持我们:** 给这个仓库点 Star,关注我们的日常发布。
|
||||
|
||||
---
|
||||
|
||||
## 开发
|
||||
|
||||
想要贡献代码或在本地运行?请参阅我们的 [CONTRIBUTING.md](CONTRIBUTING.md) 指南。
|
||||
想要贡献代码或在本地运行?请参阅我们的 [CONTRIBUTING.md](../.github/CONTRIBUTING.md) 指南。
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<!-- Claude starburst glyph — matches the in-app ClaudeIcon in src/renderer/src/components/status-bar/icons.tsx so README and app agree. -->
|
||||
<path fill="#D97757" fill-rule="nonzero" d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.1 KiB |
@@ -1,138 +0,0 @@
|
||||
# Back/Forward support for the Tasks page
|
||||
|
||||
## Goal
|
||||
|
||||
Make the titlebar back/forward buttons and the `Cmd/Ctrl+Alt+←/→` shortcut
|
||||
traverse Tasks visits in addition to worktree activations. Today both are
|
||||
no-ops outside `activeView === 'terminal'` (see `App.tsx:616`, `App.tsx:900`).
|
||||
|
||||
## Current shape
|
||||
|
||||
- History lives in `src/renderer/src/store/slices/worktree-nav-history.ts`.
|
||||
- Entries are `string[]` (worktree IDs), recorded from
|
||||
`worktree-activation.ts:96` via `recordWorktreeVisit`.
|
||||
- `goBackWorktree`/`goForwardWorktree` skip dead worktrees via
|
||||
`findPrev/NextLiveWorktreeHistoryIndex`, gated by an `activator` ref and
|
||||
`isNavigatingHistory` to prevent re-recording.
|
||||
- UI (pre-change): titlebar buttons hidden unless
|
||||
`activeView === 'terminal'`; shortcut ignored outside terminal view.
|
||||
|
||||
## Minimal implementation (downscoped)
|
||||
|
||||
1. **History entry type** → `string | 'tasks'`.
|
||||
- `findPrev/NextLiveWorktreeHistoryIndex` must short-circuit on `'tasks'`
|
||||
before calling `findWorktreeById` (which takes a worktree id, not a
|
||||
view tag) — Tasks entries are unconditionally live.
|
||||
- Keep `recordWorktreeVisit(worktreeId: string)` signature to avoid churn
|
||||
in `terminals-hydration.test.ts`. Add a sibling
|
||||
`recordViewVisit(entry: 'tasks')` that shares the dedupe/truncate/cap
|
||||
logic.
|
||||
- Keep existing names (`worktreeNavHistory`, `goBackWorktree`,
|
||||
`canGoBackWorktreeHistory`). They're now slight misnomers since
|
||||
entries may be `'tasks'`, but renaming touches ~20 call sites for no
|
||||
behavioral win. Add a one-line comment at the top of the slice
|
||||
stating the entry type and the intentional keeping of the name, so
|
||||
future readers don't assume it's worktree-only.
|
||||
|
||||
2. **Record Tasks visits** → call `recordViewVisit('tasks')` from
|
||||
`openTaskPage` (`ui.ts:189`). No `isNavigatingHistory` guard needed:
|
||||
back-to-Tasks routes through `setActiveView('tasks')` per step 3, which
|
||||
never touches `openTaskPage`. The slice's existing adjacent-entry dedupe
|
||||
covers any other re-entry. Note: `openTaskPage` is called with varying
|
||||
`taskPageData` (e.g. `{ taskSource: 'github' }` vs `'linear'` from
|
||||
`SidebarNav.tsx:88,102`); all collapse to a single `'tasks'` entry and
|
||||
dedupe against the current entry, so toggling presets produces no extra
|
||||
history entries. This is consistent with the out-of-scope "no per-entry
|
||||
snapshotting" decision.
|
||||
|
||||
3. **Dispatch in goBack/goForward** → based on entry kind:
|
||||
- worktreeId → existing `activator(worktreeId)` path. When current entry
|
||||
is `'tasks'`, no extra view handling is needed:
|
||||
`activateAndRevealWorktree` at `worktree-activation.ts:82-84` already
|
||||
switches `activeView` back to `'terminal'`.
|
||||
- `'tasks'` → `setActiveView('tasks')` (not `openTaskPage` — avoids
|
||||
mutating `previousViewBeforeTasks` and the SWR prefetch).
|
||||
|
||||
4. **Fix Esc-close history desync.** `closeTaskPage` (`ui.ts:210`) currently
|
||||
sets `activeView` to `previousViewBeforeTasks` without touching the
|
||||
history index, so after `A → Tasks → Esc` the index still points at the
|
||||
`'tasks'` entry and Back becomes a visual no-op (activator re-activates A)
|
||||
while Forward re-opens Tasks. Fix: in `closeTaskPage`, if the current
|
||||
history entry is `'tasks'`, move the index to the previous live entry
|
||||
(same scan as `findPrevLiveWorktreeHistoryIndex`). Sub-case: if there is
|
||||
no previous live entry (e.g. user's first action was open Tasks, then
|
||||
Esc — history `['tasks']` at index 0), leave the index unchanged at 0.
|
||||
Accept the minor cost (Back becomes a visual no-op until a real visit
|
||||
records a new entry) rather than setting to -1, which would lose the
|
||||
only forward target. Guard with `isNavigatingHistory` is unnecessary
|
||||
here — `closeTaskPage` is never invoked from the history path.
|
||||
|
||||
5. **Unhide UI on Tasks** (allowlist, not denylist — `activeView` union is
|
||||
`'terminal' | 'settings' | 'tasks'`, but an allowlist won't silently
|
||||
include future views):
|
||||
- Replace the `activeView !== 'terminal'` early-return at `App.tsx:616`
|
||||
with `activeView !== 'terminal' && activeView !== 'tasks'`. Update the
|
||||
adjacent comment (`App.tsx:613-615`) — it currently claims the
|
||||
shortcut is a no-op outside terminal because the buttons are hidden;
|
||||
replace with: back/forward traverse worktree + Tasks visits, so the
|
||||
shortcut is active whenever the button cluster is (terminal or
|
||||
Tasks); still suppressed elsewhere (Settings).
|
||||
- Change the titlebar cluster guard at `App.tsx:900` to
|
||||
`activeView === 'terminal' || activeView === 'tasks'`. Update the
|
||||
adjacent comment (`App.tsx:896-899`) — drop the "terminal view only"
|
||||
framing; explain the cluster is shown wherever the history shortcut
|
||||
is live, and hidden in Settings to keep that view modal-ish.
|
||||
|
||||
6. **Tests**: extend `worktree-nav-history.test.ts` for mixed entries:
|
||||
- `A → Tasks → B`, back lands on Tasks, back again on A.
|
||||
- Tasks dedupe against current entry.
|
||||
- Dead worktree between Tasks entries is skipped.
|
||||
- `A → Tasks → closeTaskPage()`: index moves back to A; subsequent Back
|
||||
is a no-op, Forward re-opens Tasks.
|
||||
- `Tasks (only entry) → closeTaskPage()`: index stays at 0, Back is a
|
||||
visual no-op.
|
||||
|
||||
## Explicitly out of scope
|
||||
|
||||
- **Settings in history.** Stays modal-ish; closes via
|
||||
`previousViewBeforeSettings`.
|
||||
- **Per-entry `taskPageData` snapshotting.** Back-to-Tasks opens Tasks with
|
||||
default filters/source. Revisit if users complain.
|
||||
- **Session persistence of history.** Keep in-memory only (it already is).
|
||||
|
||||
## Edge cases handled
|
||||
|
||||
- Worktree deletion mid-session → skipped by existing live-check; Tasks
|
||||
entries always live.
|
||||
- Dedupe semantics → same adjacency rule works for `'tasks'`.
|
||||
- Activator failure on worktree → unchanged (`result !== false` gate). Tasks
|
||||
dispatch can't fail.
|
||||
- Editable-target guard at `App.tsx:600` still fires first → typing in the
|
||||
Tasks search box + `Cmd+Alt+←` remains a no-op.
|
||||
- `MAX_HISTORY = 50` → Tasks entries consume slots; worst-case effective
|
||||
worktree depth halves to ~25. Acceptable.
|
||||
|
||||
## Known residual quirks (accepted)
|
||||
|
||||
1. **Prefetch loss on back-to-Tasks.** Routing through `setActiveView('tasks')`
|
||||
skips the SWR prefetch at `ui.ts:201-208`; back-to-Tasks is ~300–800ms
|
||||
slower than a fresh open via the sidebar. Not a regression vs today
|
||||
(back-to-Tasks isn't possible at all today).
|
||||
|
||||
2. **Titlebar layout shift.** Revealing the button cluster on Tasks changes
|
||||
the titlebar — needs a visual check that nothing Tasks-specific collides.
|
||||
|
||||
## Files touched
|
||||
|
||||
- `src/renderer/src/store/slices/worktree-nav-history.ts` — entry type,
|
||||
`recordViewVisit`, dispatch in `goBack/goForwardWorktree`.
|
||||
- `src/renderer/src/store/slices/worktree-nav-history.test.ts` — new cases.
|
||||
- `src/renderer/src/store/slices/ui.ts` — `openTaskPage` records via
|
||||
`recordViewVisit`; `closeTaskPage` rewinds history index when the current
|
||||
entry is `'tasks'`.
|
||||
- `src/renderer/src/App.tsx` — widen the two `activeView === 'terminal'`
|
||||
guards to include `'tasks'`, and update the adjacent "why" comments at
|
||||
`App.tsx:613-615` and `App.tsx:896-899` to reflect the new invariant.
|
||||
|
||||
Estimated diff: ~30 lines of slice logic, 1 call site in `ui.ts`, 2 guards
|
||||
in `App.tsx`, plus tests.
|
||||
@@ -1,480 +0,0 @@
|
||||
# Design Document: Scoped Cmd+J Jump Palette for Worktrees and Browser Tabs
|
||||
|
||||
**Status:** Draft
|
||||
**Date:** 2026-04-15
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Extend Orca's existing `Cmd+J` / `Ctrl+Shift+J` worktree palette into a single app-wide jump surface with explicit scopes. The first release keeps the palette centered on two jobs:
|
||||
|
||||
- jump to a worktree
|
||||
- jump to an already-open browser page across any worktree
|
||||
|
||||
The palette opens into a lightweight scope switcher with three modes:
|
||||
|
||||
- `All`
|
||||
- `Worktrees`
|
||||
- `Browser Tabs`
|
||||
|
||||
Users can press `Tab` / `Shift+Tab` to cycle scopes without leaving the keyboard. The default contract on open remains `Worktrees`, not `All`, so existing users still land in the familiar worktree-first flow before opting into broader search. Search results stay intentionally narrow: browser-only discovery is supported because the user explicitly needs to find already-open pages across worktrees, but the palette does not become a generic "everything" bucket in v1.
|
||||
|
||||
## 1.1 Phase 0.5 Direction Lock
|
||||
|
||||
Phase 0.5 locked three product decisions for v1:
|
||||
|
||||
- `Cmd+J` still opens into `Worktrees` by default.
|
||||
- Browser discovery indexes live open `BrowserPage`s, not just browser workspace containers.
|
||||
- Browser ordering uses a simple context-first heuristic instead of true last-focused recency state.
|
||||
|
||||
Why these decisions were chosen:
|
||||
|
||||
- `Cmd+J` already has a strong worktree-switching contract in Orca. Making `All` the default would silently turn a familiar command into a mixed-ranking surface and force existing users to re-learn the first screen they see.
|
||||
- The remembered object in the browser case is the page itself. Searching only browser workspace shells would miss the page-level titles and URLs users actually recall when they say "I know I already have this open somewhere."
|
||||
- True global browser recency would require new focus-tracking state whose only initial consumer is palette ranking. A simple heuristic is easier to ship, easier to explain, and good enough for the first version of this discovery workflow.
|
||||
|
||||
## 2. Problem
|
||||
|
||||
Orca already supports multiple concurrent worktrees and persistent in-app browser tabs. Users can jump between worktrees with the existing `Cmd+J` palette, but they cannot quickly answer a more specific question:
|
||||
|
||||
> "I know I already have this page open somewhere. Which worktree is it in, and how do I get back to it?"
|
||||
|
||||
The current worktree palette is container-first. That works when the user remembers the worktree identity, but it breaks down when the remembered thing is a page title, host, or URL path.
|
||||
|
||||
This is a discovery problem, not a recency problem. A cycle UI is poor at it because:
|
||||
|
||||
- the user often does not know which worktree owns the target tab
|
||||
- multiple browser tabs can share similar titles
|
||||
- cycling scales badly once many worktrees have open tabs
|
||||
|
||||
## 3. Goals
|
||||
|
||||
- Preserve `Cmd+J` / `Ctrl+Shift+J` as Orca's single global jump entry point.
|
||||
- Let users search open browser pages across all worktrees.
|
||||
- Keep worktree search fast and familiar for existing users.
|
||||
- Make scope switching explicit and keyboard-first.
|
||||
- Avoid overcommitting Orca to a generic "search all open items" model before the product is ready to support terminals, editors, and commands consistently.
|
||||
- Preserve the existing expectation that `Cmd+J` opens as a worktree-first jump flow.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- Searching terminal scrollback or editor contents.
|
||||
- Adding a persistent sidebar or browser-tab manager panel.
|
||||
- Replacing local `Ctrl+Tab` behavior or introducing a new cycle UI.
|
||||
- Expanding `Cmd+J` into files, terminals, editor tabs, or commands in this change.
|
||||
|
||||
## 5. UX
|
||||
|
||||
### 5.1 Entry Point
|
||||
|
||||
Keep the existing shortcut:
|
||||
|
||||
- macOS: `Cmd+J`
|
||||
- Windows/Linux: `Ctrl+Shift+J`
|
||||
|
||||
This shortcut already means "global jump" inside Orca and is already forwarded correctly even when an embedded browser guest owns focus. Reusing it preserves muscle memory and avoids proliferating navigation surfaces.
|
||||
|
||||
### 5.2 Scope Model
|
||||
|
||||
The palette header contains three explicit scope chips:
|
||||
|
||||
- `All`
|
||||
- `Worktrees`
|
||||
- `Browser Tabs`
|
||||
|
||||
Keyboard behavior:
|
||||
|
||||
- `Tab`: next scope
|
||||
- `Shift+Tab`: previous scope
|
||||
- `Up` / `Down`: move selection within results
|
||||
- `Enter`: activate selected result
|
||||
- `Esc`: close palette
|
||||
|
||||
Default scope on open: `Worktrees`.
|
||||
|
||||
Why this shape:
|
||||
|
||||
- one entry point is easier to remember than separate dialogs
|
||||
- explicit scopes prevent a mixed list from becoming noisy
|
||||
- `Tab` matches the mental model of "move across modes" without conflicting with list navigation
|
||||
- opening in `Worktrees` preserves today's default behavior and makes `All` an explicit expansion, not a silent contract change
|
||||
|
||||
### 5.3 Scope Semantics
|
||||
|
||||
#### `All`
|
||||
|
||||
Merged result list of:
|
||||
|
||||
- open browser pages across all worktrees
|
||||
- worktrees across all repos
|
||||
|
||||
Ranking rules:
|
||||
|
||||
- strong browser title matches rank above weak worktree metadata matches
|
||||
- host/url matches get boosted when the query resembles a domain, URL, or path fragment
|
||||
- exact worktree name matches still beat weak browser matches
|
||||
- current worktree/current browser page receive a small context boost when otherwise tied
|
||||
- browser results use the same heuristic ordering as `Browser Tabs`; v1 does not add hidden last-focused browser recency state just for this merged scope
|
||||
|
||||
`All` is meant to feel smart, not exhaustive.
|
||||
|
||||
#### `Worktrees`
|
||||
|
||||
Equivalent to today's worktree palette behavior:
|
||||
|
||||
- same global search semantics for worktree metadata
|
||||
- same recent-first ordering for the default empty-query state
|
||||
- same selection and activation behavior
|
||||
|
||||
#### `Browser Tabs`
|
||||
|
||||
Shows only open browser pages across all worktrees. The user-facing scope label stays `Browser Tabs`, but each row maps to a live `BrowserPage`, not a browser workspace shell.
|
||||
|
||||
Empty query ordering:
|
||||
|
||||
1. current browser page, if any
|
||||
2. other open browser pages in the current worktree
|
||||
3. browser pages in other worktrees, grouped by the existing worktree ordering and then sorted by title, falling back to URL
|
||||
|
||||
This mode is the direct answer to "just show me all open browsers."
|
||||
|
||||
Why this ordering:
|
||||
|
||||
- it pulls the user's current context to the top without inventing new global recency state
|
||||
- it stays stable enough that users can learn where results tend to land
|
||||
- it keeps the implementation honest about what Orca already knows today versus what would require new focus-history plumbing
|
||||
|
||||
### 5.4 Result Rows
|
||||
|
||||
#### Browser tab row
|
||||
|
||||
Primary text:
|
||||
|
||||
- current page title, falling back to formatted URL when the title is blank or useless
|
||||
|
||||
Secondary text:
|
||||
|
||||
- host + trimmed path
|
||||
|
||||
Context chips on the right:
|
||||
|
||||
- repo name
|
||||
- worktree display name
|
||||
|
||||
Optional badges:
|
||||
|
||||
- `Current Tab`
|
||||
- `Current Worktree`
|
||||
|
||||
Why this is required:
|
||||
|
||||
- browser tab titles are often duplicated (`localhost`, `Settings`, `Dashboard`)
|
||||
- users need immediate disambiguation without opening the result
|
||||
- worktree context is the whole point of the feature
|
||||
- each row represents the actual page the user remembers, while worktree and repo chips explain where that page lives
|
||||
|
||||
#### Worktree row
|
||||
|
||||
Keep the existing row structure:
|
||||
|
||||
- worktree display name
|
||||
- branch
|
||||
- optional supporting text for comment / PR / issue
|
||||
- repo badge
|
||||
|
||||
This avoids making existing users relearn the palette.
|
||||
|
||||
### 5.5 Empty States
|
||||
|
||||
`All`
|
||||
|
||||
- If no worktrees and no browser tabs exist: `No worktrees or open browser tabs`
|
||||
- If query yields no results: `No matches in worktrees or browser tabs`
|
||||
|
||||
`Worktrees`
|
||||
|
||||
- Preserve existing copy
|
||||
|
||||
`Browser Tabs`
|
||||
|
||||
- No open browser tabs: `No open browser tabs`
|
||||
- No query matches: `No browser tabs match your search`
|
||||
|
||||
### 5.6 Activation Behavior
|
||||
|
||||
Selecting a worktree result:
|
||||
|
||||
- preserve current `activateAndRevealWorktree(worktreeId)` behavior
|
||||
|
||||
Selecting a browser result:
|
||||
|
||||
1. activate and reveal the owning worktree
|
||||
2. focus the target browser workspace tab
|
||||
3. select the target `BrowserPage` inside that workspace
|
||||
4. set `activeTabType` to `browser`
|
||||
5. close the palette
|
||||
6. restore focus into the browser surface, not the terminal/editor fallback
|
||||
|
||||
Why this ordering matters:
|
||||
|
||||
- browser pages are subordinate to worktree activation in Orca's model
|
||||
- worktree-first activation restores the right workspace state and sidebar visibility
|
||||
- selecting a browser result should feel like "take me there directly," not "switch worktree and make me pick again"
|
||||
|
||||
## 6. Data Model and Search Inputs
|
||||
|
||||
### 6.1 Worktree Results
|
||||
|
||||
Use the existing search surface:
|
||||
|
||||
- `displayName`
|
||||
- branch
|
||||
- repo name
|
||||
- comment
|
||||
- linked PR number/title
|
||||
- linked issue number/title
|
||||
|
||||
### 6.2 Browser Tab Results
|
||||
|
||||
Search only currently open browser pages, not history and not just browser workspace shells.
|
||||
|
||||
For each browser result, index:
|
||||
|
||||
- page title
|
||||
- page URL
|
||||
- formatted host/path
|
||||
- owning browser workspace label, if available
|
||||
- owning worktree display name
|
||||
- owning repo display name
|
||||
|
||||
Each open `BrowserPage` contributes its own result row. Browser workspaces still matter for ownership and activation, but they are context, not the searchable unit.
|
||||
|
||||
This is intentionally limited to live open pages because Orca still is not an app-wide browsing history system. The goal is to help users recover something they already have open, not to introduce a second browsing history feature through the palette.
|
||||
|
||||
### 6.3 Why Not Terminal Tabs Yet
|
||||
|
||||
Terminal tabs are deliberately out of scope for text-first search in this change.
|
||||
|
||||
Reasons:
|
||||
|
||||
- terminal tab titles are less stable and less descriptive than browser titles
|
||||
- the meaningful part of a terminal session often lives in scrollback, not tab metadata
|
||||
- adding terminal tabs only because browser tabs are added would create a low-signal mixed palette
|
||||
|
||||
This design keeps the palette honest: it supports browser-page search because that metadata makes the target genuinely searchable. If Orca later wants an "all open items" palette, that should be a deliberate follow-up with result quality standards for each item type.
|
||||
|
||||
## 7. Architecture
|
||||
|
||||
### 7.1 Existing Pieces Reused
|
||||
|
||||
- `WorktreeJumpPalette.tsx` remains the base surface and interaction shell.
|
||||
- Existing worktree search logic remains intact for the `Worktrees` scope.
|
||||
- Browser search input comes from the live open `BrowserPage`s already held by renderer browser state.
|
||||
- Browser activation continues to use the existing browser-workspace activation pathway after worktree activation, then selects the matching page inside that workspace.
|
||||
- Main-process shortcut forwarding remains unchanged.
|
||||
|
||||
### 7.2 New Search Model
|
||||
|
||||
Add a palette view-model layer that produces typed results:
|
||||
|
||||
```ts
|
||||
type JumpPaletteScope = 'all' | 'worktrees' | 'browser-tabs'
|
||||
|
||||
type JumpPaletteResult =
|
||||
| { type: 'worktree'; worktreeId: string; score: number; ... }
|
||||
| {
|
||||
type: 'browser-page'
|
||||
worktreeId: string
|
||||
browserTabId: string
|
||||
browserPageId: string
|
||||
score: number
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The existing worktree search helper remains responsible for worktree scoring. A new browser-page search helper handles browser result scoring and formatting. The palette shell merges and sorts results only in `All`.
|
||||
|
||||
Why split the search helpers:
|
||||
|
||||
- worktree matching logic is already non-trivial and should not be regressed
|
||||
- browser result ranking has different signals than worktree ranking
|
||||
- typed results keep selection and rendering explicit instead of relying on ad hoc ID prefixes
|
||||
- page-level browser hits need both workspace ownership and page identity for activation
|
||||
|
||||
### 7.3 Focus and Close Semantics
|
||||
|
||||
The existing palette already manages focus restoration carefully. That logic should be extended, not replaced.
|
||||
|
||||
New rule:
|
||||
|
||||
- if the selected result is a browser page, the post-close focus path targets the active browser surface
|
||||
- otherwise preserve today's terminal/editor focus restoration behavior
|
||||
|
||||
### 7.4 System Context
|
||||
|
||||
```text
|
||||
+------------------+ +-----------------------+
|
||||
| Main Process | | Renderer Store |
|
||||
| shortcut forward | -----> | activeModal |
|
||||
| Cmd+J / Ctrl+... | | worktreesByRepo |
|
||||
+------------------+ | browser state |
|
||||
| activeWorktreeId |
|
||||
+-----------+-----------+
|
||||
|
|
||||
v
|
||||
+------------------------+
|
||||
| Cmd+J Jump Palette |
|
||||
| scopes + search + list |
|
||||
+-----+-------------+----+
|
||||
| |
|
||||
worktree hit | | browser-page hit
|
||||
v v
|
||||
+------------------+ +---------------------+
|
||||
| activate/reveal | | activate/reveal |
|
||||
| target worktree | | target worktree |
|
||||
+------------------+ +----------+----------+
|
||||
|
|
||||
v
|
||||
+----------------------+
|
||||
| activate browser |
|
||||
| workspace + page |
|
||||
| focus browser pane |
|
||||
+----------------------+
|
||||
```
|
||||
|
||||
### 7.5 Data Flows
|
||||
|
||||
#### Happy path: browser-page search and jump
|
||||
|
||||
```text
|
||||
Cmd+J -> palette opens in Worktrees -> user switches to Browser Tabs ->
|
||||
query matches browser page ->
|
||||
user presses Enter -> activateAndRevealWorktree(worktreeId) ->
|
||||
activate target browser workspace and page -> palette closes -> browser surface focused
|
||||
```
|
||||
|
||||
#### Nil path: user opens palette with no browser pages
|
||||
|
||||
```text
|
||||
Cmd+J -> palette opens -> Browser Tabs scope selected ->
|
||||
search model sees zero browser pages -> empty state shown -> no side effects
|
||||
```
|
||||
|
||||
#### Empty path: query yields no browser or worktree matches
|
||||
|
||||
```text
|
||||
query typed -> search returns [] -> scope-specific empty state rendered ->
|
||||
selection cleared or pinned to no result -> Enter does nothing
|
||||
```
|
||||
|
||||
#### Upstream error path: selected browser page disappears before activation
|
||||
|
||||
```text
|
||||
user selects browser result -> store lookup fails because page/worktree closed ->
|
||||
show toast error -> keep palette open if possible, otherwise close safely without switching
|
||||
```
|
||||
|
||||
## 8. Alternatives Considered
|
||||
|
||||
### 8.1 Dedicated browser-tab-only dialog
|
||||
|
||||
Pros:
|
||||
|
||||
- clearer mental model for the browser-specific job
|
||||
- no mixed-result ranking complexity
|
||||
|
||||
Cons:
|
||||
|
||||
- adds another shortcut and another navigation surface
|
||||
- weakens `Cmd+J` as the single place to jump around Orca
|
||||
|
||||
Decision: rejected for now. The scoped palette gives the same utility with less surface area.
|
||||
|
||||
### 8.2 Browser tabs only inside `Cmd+J`, no scopes
|
||||
|
||||
Pros:
|
||||
|
||||
- least new UI chrome
|
||||
|
||||
Cons:
|
||||
|
||||
- mixed results become harder to reason about
|
||||
- users cannot quickly answer "just show me browser tabs"
|
||||
|
||||
Decision: rejected. Explicit scopes are worth the small extra header chrome.
|
||||
|
||||
### 8.3 Expand immediately to terminals, files, and commands
|
||||
|
||||
Pros:
|
||||
|
||||
- one "go to anything" story
|
||||
|
||||
Cons:
|
||||
|
||||
- scope explosion
|
||||
- result quality is uneven across item types
|
||||
- much higher design and implementation complexity
|
||||
|
||||
Decision: rejected for v1. Start with the two jobs the user clearly asked for.
|
||||
|
||||
### 8.4 Make `All` the default scope
|
||||
|
||||
Pros:
|
||||
|
||||
- makes browser discovery visible immediately
|
||||
- creates a more obviously "global" first impression
|
||||
|
||||
Cons:
|
||||
|
||||
- breaks the current worktree-first contract of `Cmd+J`
|
||||
- makes the first screen depend on mixed ranking logic instead of today's predictable worktree list
|
||||
|
||||
Decision: rejected for v1. `All` remains available, but opening in `Worktrees` preserves muscle memory and keeps the expansion explicit.
|
||||
|
||||
### 8.5 Search browser workspaces instead of live pages
|
||||
|
||||
Pros:
|
||||
|
||||
- simpler indexing model
|
||||
- reuses the existing browser workspace abstraction directly
|
||||
|
||||
Cons:
|
||||
|
||||
- misses the page titles and URLs users actually remember
|
||||
- treats the container as the search target even when the user wants a specific page inside it
|
||||
|
||||
Decision: rejected. The palette should index the page the user is trying to recover, then use workspace and worktree context to explain where it lives.
|
||||
|
||||
### 8.6 Add true browser recency tracking for v1 ranking
|
||||
|
||||
Pros:
|
||||
|
||||
- could produce sharper empty-query ordering over time
|
||||
|
||||
Cons:
|
||||
|
||||
- requires new state and focus bookkeeping for a thin initial payoff
|
||||
- introduces ranking behavior that is harder to explain and debug
|
||||
|
||||
Decision: rejected for v1. Start with a deterministic context-first heuristic and revisit true recency only if usage shows the heuristic is insufficient.
|
||||
|
||||
## 9. Rollout
|
||||
|
||||
### Phase 1
|
||||
|
||||
- Add scoped header to the existing `Cmd+J` palette
|
||||
- Keep `Worktrees` as the default scope on open
|
||||
- Preserve worktree-only behavior under the `Worktrees` scope
|
||||
- Add browser-page search and activation
|
||||
- Search live open `BrowserPage`s rather than browser workspace shells
|
||||
- Add `All` merged ranking
|
||||
|
||||
### Phase 2 (optional follow-up)
|
||||
|
||||
- Evaluate whether users need additional scopes such as editor tabs or commands
|
||||
- Only add a new scope if it has a clear, high-signal search model
|
||||
|
||||
## 10. Open Questions
|
||||
|
||||
- Whether a later iteration should remember a last-used non-default scope without changing the default-open `Worktrees` contract
|
||||
- Whether browser-tab results should expose close actions from the palette in a later pass
|
||||
- Whether `All` should group results visually by type or keep one flat ranked list
|
||||
@@ -1,451 +0,0 @@
|
||||
# Codex Account Switching Design
|
||||
|
||||
**Status:** Draft
|
||||
**Date:** 2026-04-17
|
||||
|
||||
## Summary
|
||||
|
||||
Orca's current Codex account switcher swaps the entire `CODEX_HOME` for each managed account. That isolates authentication, but it also unintentionally forks config, permissions, history, sessions, memories, skills, and other local Codex state. The result is that account switching feels like switching between separate Codex installs instead of switching which account powers the same Codex environment.
|
||||
|
||||
The recommended design is:
|
||||
|
||||
- Keep `~/.codex` as the shared runtime `CODEX_HOME` for all Codex user state.
|
||||
- Store only `auth.json` per managed account in Orca-owned storage.
|
||||
- Introduce a dedicated main-process runtime-home owner that materializes the selected account's `auth.json` into the shared runtime home before any Codex launch, login, or rate-limit fetch.
|
||||
- Restart live Codex panes after switch; newly launched panes use the new account while preserving the shared Codex state.
|
||||
|
||||
This matches the intended product behavior: account switching is for authentication and usage limits, not for creating separate Codex worlds. It also matches how manual Codex account switching already behaves outside Orca: logging out and back in mutates the same `~/.codex` state the user sees in terminal Codex.
|
||||
|
||||
## Motivation
|
||||
|
||||
The current managed-account design causes user-visible problems:
|
||||
|
||||
- `config.toml` diverges per account, so permissions and sandbox defaults reset unexpectedly.
|
||||
- `history.jsonl` and `sessions/` are scoped per managed home, so chat history appears to disappear after account switches.
|
||||
- `memories`, `skills`, `rules`, and likely sqlite-backed local state drift per account.
|
||||
- Live sessions require restart because the active terminal process keeps using the old `CODEX_HOME`.
|
||||
|
||||
We already patched the first symptom by syncing `config.toml` into managed homes. That is a tactical fix, not the right long-term model. The full-home-per-account design still leaves history and session continuity split across accounts.
|
||||
|
||||
The deeper issue is ownership. Orca currently has no single component that owns Codex runtime state preparation. `CodexAccountService`, `pty.ts`, rate-limit fetchers, and usage scanning each participate in path or environment decisions. The long-term fix must therefore be a runtime-home ownership refactor, not just a storage-layout tweak.
|
||||
|
||||
## Current State
|
||||
|
||||
### Orca behavior today
|
||||
|
||||
- Managed accounts are created under `app.getPath('userData')/codex-accounts/<id>/home`.
|
||||
- Orca selects an account by updating `settings.activeCodexManagedAccountId`.
|
||||
- New Codex PTYs inherit the selected managed home's path as `CODEX_HOME`.
|
||||
- Codex rate-limit fetches also use that selected managed home.
|
||||
|
||||
Relevant code:
|
||||
|
||||
- [src/main/codex-accounts/service.ts](/Users/jinwoohong/orca/workspaces/orca/codex-fix-2/src/main/codex-accounts/service.ts)
|
||||
- [src/main/ipc/pty.ts](/Users/jinwoohong/orca/workspaces/orca/codex-fix-2/src/main/ipc/pty.ts)
|
||||
- [src/main/codex-usage/scanner.ts](/Users/jinwoohong/orca/workspaces/orca/codex-fix-2/src/main/codex-usage/scanner.ts)
|
||||
|
||||
### Codex state observed on disk
|
||||
|
||||
On a typical install, `CODEX_HOME` contains at least:
|
||||
|
||||
- `auth.json`
|
||||
- `config.toml`
|
||||
- `history.jsonl`
|
||||
- `sessions/`
|
||||
- `memories/`
|
||||
- `skills/`
|
||||
- `rules/`
|
||||
- `shell_snapshots/`
|
||||
- `models_cache.json`
|
||||
- `logs_2.sqlite`
|
||||
- `state_5.sqlite`
|
||||
- `installation_id`
|
||||
- `version.json`
|
||||
|
||||
Because Orca currently swaps the whole home, all of that becomes account-scoped.
|
||||
|
||||
## Goals
|
||||
|
||||
- Make Codex account switching feel like swapping credentials, not swapping environments.
|
||||
- Preserve one continuous Codex history and session store across accounts.
|
||||
- Keep permissions, sandbox defaults, MCP config, memories, and other user state stable across account changes.
|
||||
- Preserve Orca's existing account-switch UX: one selected active account at a time, with restart prompts for live Codex panes.
|
||||
- Keep the solution cross-platform across macOS, Linux, and Windows.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Supporting simultaneous live Codex sessions under different accounts in the same Orca instance.
|
||||
- Changing Codex upstream behavior or requiring first-class multi-account support from Codex.
|
||||
- Building a replication system that continuously merges multiple independent `CODEX_HOME` trees.
|
||||
|
||||
## Constraints
|
||||
|
||||
### Codex does not currently expose a separate auth path
|
||||
|
||||
From local CLI inspection, Codex supports:
|
||||
|
||||
- `CODEX_HOME`
|
||||
- config overrides via `-c key=value`
|
||||
|
||||
It does not currently expose a first-class "shared config/home plus separate auth profile" interface. There is an upstream feature request for auth profiles, which suggests Orca cannot rely on such a feature today.
|
||||
|
||||
### Orca is cross-platform
|
||||
|
||||
The design must work on macOS, Linux, and Windows. That rules out relying on symlink-heavy designs as the primary solution:
|
||||
|
||||
- Windows symlink/junction behavior is more fragile.
|
||||
- Atomic copy + rename is simpler and more portable.
|
||||
- Node path utilities should be used everywhere.
|
||||
|
||||
### Orca's current mental model is single selected account
|
||||
|
||||
The current switcher already assumes one active account at a time and uses restart prompts for live Codex panes. The recommended design leans into that model rather than trying to support concurrent mixed-account sessions.
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option 1: Keep full per-account homes and sync everything
|
||||
|
||||
Each managed account keeps its own full `CODEX_HOME`, and Orca syncs:
|
||||
|
||||
- `config.toml`
|
||||
- `history.jsonl`
|
||||
- `sessions/`
|
||||
- sqlite state
|
||||
- memories, skills, rules
|
||||
|
||||
**Pros**
|
||||
|
||||
- Minimal conceptual change from the current design.
|
||||
- Per-account auth isolation stays simple.
|
||||
|
||||
**Cons**
|
||||
|
||||
- Orca becomes a replication system for Codex state.
|
||||
- `history.jsonl` is mergeable, but `sessions/` and sqlite-backed files are much harder to reconcile safely.
|
||||
- Concurrent activity can easily cause stale-copy or overwrite bugs.
|
||||
- More code, more edge cases, weaker guarantees.
|
||||
|
||||
**Verdict**
|
||||
|
||||
Not recommended. This is the highest-complexity path for the weakest product result.
|
||||
|
||||
### Option 2: Shared runtime home, per-account `auth.json`
|
||||
|
||||
Keep a single shared runtime `CODEX_HOME` and store one `auth.json` per managed account. On switch, Orca copies the selected account's `auth.json` into the shared runtime home before launching or restarting Codex sessions.
|
||||
|
||||
**Pros**
|
||||
|
||||
- Cleanest mapping to the product intent.
|
||||
- `config.toml`, history, sessions, memories, skills, rules, and local state are naturally shared.
|
||||
- No state replication logic.
|
||||
- Cross-platform implementation is straightforward with normal file copy and rename.
|
||||
|
||||
**Cons**
|
||||
|
||||
- Does not support simultaneous different-account live sessions in the same runtime home.
|
||||
- Existing live sessions still likely need restart because Codex may read auth only at startup.
|
||||
|
||||
**Verdict**
|
||||
|
||||
Recommended.
|
||||
|
||||
### Option 3: Wait for upstream Codex auth profiles
|
||||
|
||||
If Codex eventually supports a true auth-profile model, Orca could delegate account isolation to Codex itself.
|
||||
|
||||
**Pros**
|
||||
|
||||
- Best long-term upstream integration.
|
||||
- Less Orca-specific state management.
|
||||
|
||||
**Cons**
|
||||
|
||||
- Not available today.
|
||||
- Does not solve Orca's user-facing problems now.
|
||||
|
||||
**Verdict**
|
||||
|
||||
Good future migration target, not a current solution.
|
||||
|
||||
## Recommended Design
|
||||
|
||||
### High-level model
|
||||
|
||||
Introduce two separate concepts:
|
||||
|
||||
1. **Shared runtime home**
|
||||
The single `CODEX_HOME` used for all Codex launches inside Orca. For this design, the canonical shared runtime home is `~/.codex`.
|
||||
|
||||
2. **Per-account auth store**
|
||||
Orca-managed storage that keeps one `auth.json` per account.
|
||||
|
||||
3. **Codex runtime-home owner**
|
||||
A dedicated main-process component that prepares active Codex runtime state before any Codex subprocess, rate-limit fetch, or login flow touches it.
|
||||
|
||||
At runtime:
|
||||
|
||||
- Orca picks the selected managed account.
|
||||
- The runtime-home owner copies that account's `auth.json` into `~/.codex/auth.json`.
|
||||
- All Codex entry points consume the runtime-home owner's resolved home path instead of reasoning about Codex paths independently.
|
||||
- Orca launches Codex with `CODEX_HOME` pointing to `~/.codex`.
|
||||
|
||||
### Shared vs per-account state
|
||||
|
||||
**Shared runtime home**
|
||||
|
||||
- `config.toml`
|
||||
- `history.jsonl`
|
||||
- `sessions/`
|
||||
- `memories/`
|
||||
- `skills/`
|
||||
- `rules/`
|
||||
- `shell_snapshots/`
|
||||
- `models_cache.json`
|
||||
- `logs_2.sqlite`
|
||||
- `state_5.sqlite`
|
||||
- `installation_id`
|
||||
- `version.json`
|
||||
- transient caches and temp dirs, unless we later discover they must be treated specially
|
||||
|
||||
**Per-account storage**
|
||||
|
||||
- `auth.json`
|
||||
- Orca account metadata already stored in Orca settings
|
||||
|
||||
### Why this is the right split
|
||||
|
||||
`auth.json` is the only file we explicitly know needs to vary by account. The rest of the files represent user environment, session continuity, and local Codex behavior. If those are split by account, the switcher does not feel seamless.
|
||||
|
||||
## Detailed Design
|
||||
|
||||
### Storage layout
|
||||
|
||||
Recommended paths:
|
||||
|
||||
- Shared runtime home:
|
||||
- `~/.codex`
|
||||
- Per-account auth store:
|
||||
- `app.getPath('userData')/codex-accounts/<id>/home/auth.json`
|
||||
|
||||
The important part is the separation of concerns, not the exact path choice.
|
||||
|
||||
### Ownership and API
|
||||
|
||||
The design should introduce a dedicated main-process owner for runtime-home preparation. A representative API shape:
|
||||
|
||||
```ts
|
||||
type PreparedCodexRuntime = {
|
||||
homePath: string
|
||||
activeAccountId: string | null
|
||||
}
|
||||
|
||||
interface CodexRuntimeHomeService {
|
||||
prepareForAccountSwitch(accountId: string | null): PreparedCodexRuntime
|
||||
prepareForCodexLaunch(): PreparedCodexRuntime
|
||||
prepareForRateLimitFetch(): PreparedCodexRuntime
|
||||
prepareForLogin(accountId: string): { loginHomePath: string }
|
||||
}
|
||||
```
|
||||
|
||||
Why: today path/runtime ownership is fragmented across `CodexAccountService`, PTY spawn env injection, rate-limit fetches, and usage scanning. A single owner prevents those code paths from drifting again.
|
||||
|
||||
### Serialization contract
|
||||
|
||||
Because `~/.codex/auth.json` is shared mutable state, the runtime-home owner must be the only component allowed to mutate active Codex auth. It must serialize these operations behind one coordination primitive:
|
||||
|
||||
- `prepareForAccountSwitch`
|
||||
- `prepareForCodexLaunch`
|
||||
- `prepareForRateLimitFetch`
|
||||
- `prepareForLogin`
|
||||
- any future reauth or logout helpers
|
||||
|
||||
Required contract:
|
||||
|
||||
- account switch auth materialization is exclusive
|
||||
- launch and rate-limit preparation must either observe the auth state from before the switch or the fully committed auth state from after the switch
|
||||
- they must never observe an in-progress partial write
|
||||
- login preparation must not mutate the active runtime auth in place
|
||||
|
||||
Why: without this contract, PTY launch, quota fetch, and auth swap can still race and intermittently bind work to the wrong account.
|
||||
|
||||
### Account switch flow
|
||||
|
||||
1. User selects a managed account.
|
||||
2. Orca validates that account's stored `auth.json`.
|
||||
3. The runtime-home owner writes the selected `auth.json` into `~/.codex/auth.json`.
|
||||
4. Orca refreshes Codex rate-limit state using the same prepared runtime home.
|
||||
5. Orca prompts restart for live Codex panes, marks them stale until restarted, and blocks further Codex execution from those panes.
|
||||
6. New or restarted Codex panes launch with:
|
||||
- `CODEX_HOME=~/.codex`
|
||||
- shared config/history/session state
|
||||
- selected account auth
|
||||
|
||||
Interaction states that must be explicit in product/UI copy:
|
||||
|
||||
- **Switch in progress**: selection disabled while auth materialization and rate-limit refresh run.
|
||||
- **Switch complete, restart required**: existing live Codex panes are stale, must show a restart affordance, and must not be allowed to submit further Codex work until restarted.
|
||||
- **Switch failed**: active account remains unchanged and stale restart notices are not applied.
|
||||
- **Switch to system default**: Orca clears managed-account selection and restores the system-default auth snapshot into `~/.codex`.
|
||||
|
||||
### System default source of truth
|
||||
|
||||
This design treats “System default” as a first-class auth source, not as “whatever happens to be left in `~/.codex/auth.json`.”
|
||||
|
||||
Rules:
|
||||
|
||||
- On first startup of the new architecture, before any managed-account switch mutates `~/.codex/auth.json`, Orca captures a `system-default` auth snapshot from the current `~/.codex/auth.json` when present.
|
||||
- That snapshot is stored in Orca-owned storage separately from managed account auth blobs.
|
||||
- Switching to “System default” restores `~/.codex/auth.json` from that stored snapshot.
|
||||
- If the user changes external Codex auth outside Orca and wants Orca’s “System default” target to follow it, Orca should expose an explicit refresh/import action or perform refresh only at startup before any managed account takes ownership in the current app session.
|
||||
|
||||
Why: without a defined snapshot-and-restore model, switching back to “System default” is nondeterministic and can leave the last managed account active or overwrite the user’s expected external Codex auth.
|
||||
|
||||
### New account add flow
|
||||
|
||||
1. Orca prepares a temporary login home that inherits the current shared config baseline but does not dirty the active runtime state on failure.
|
||||
2. Orca runs `codex login` against that temporary login home.
|
||||
3. Orca captures the resulting `auth.json`.
|
||||
4. Orca stores only that `auth.json` under the managed account's storage.
|
||||
5. Orca does not change the active runtime home until the user selects that account or explicitly makes it active on completion.
|
||||
|
||||
Why: a failed or aborted login must not poison the currently active `~/.codex` runtime state.
|
||||
|
||||
### Legacy managed-home migration
|
||||
|
||||
Migration of existing managed-home history and sessions is required, not optional.
|
||||
|
||||
Rules:
|
||||
|
||||
- On first startup after the new architecture lands, Orca scans legacy managed homes for:
|
||||
- `history.jsonl`
|
||||
- `sessions/`
|
||||
- Orca imports legacy history into `~/.codex/history.jsonl` using append/merge semantics that avoid dropping existing shared-home history.
|
||||
- Orca imports legacy sessions into `~/.codex/sessions/` with an explicit collision policy:
|
||||
- import non-conflicting legacy session files directly
|
||||
- for conflicting session files, merge turns when Orca can prove the files represent the same logical session with append-only divergence
|
||||
- if Orca cannot safely merge a conflicting session file, preserve both copies under deterministic names and emit a diagnostic record rather than silently dropping either side
|
||||
- Orca records migration completion so the import does not repeat on every startup.
|
||||
|
||||
Why: shared history/session continuity is a core goal of the design. Leaving legacy managed-home data unresolved would make the upgrade look like history loss for users who previously used managed accounts.
|
||||
|
||||
### Live session behavior
|
||||
|
||||
The safe assumption is:
|
||||
|
||||
- switching accounts affects new Codex launches
|
||||
- existing live Codex sessions should still restart
|
||||
- stale live Codex panes are blocked from further execution until restart completes
|
||||
|
||||
If future validation shows Codex hot-reloads `auth.json`, Orca can relax this. The architecture should not rely on hot-reload behavior today.
|
||||
|
||||
### Startup and recovery behavior
|
||||
|
||||
The design must explicitly cover startup and error handling:
|
||||
|
||||
- If `~/.codex` exists but has no `history.jsonl` or `sessions/`, Orca should launch cleanly and let Codex create them lazily.
|
||||
- If the selected managed account's stored `auth.json` is missing or corrupt, Orca should:
|
||||
- log a recoverable warning,
|
||||
- fall back to system-default semantics,
|
||||
- clear or mark invalid the selected managed account,
|
||||
- avoid leaving rate-limit UI bound to the wrong identity.
|
||||
- If a rate-limit refresh fails after account switch, Orca should keep the account switch result but show quota fetch failure separately rather than rolling back auth materialization implicitly.
|
||||
|
||||
## Why `~/.codex` Is The Shared Home
|
||||
|
||||
This document explicitly chooses `~/.codex` as the canonical shared runtime home.
|
||||
|
||||
Reasons:
|
||||
|
||||
- It matches the user's existing Codex mental model inside and outside Orca.
|
||||
- It avoids split-brain between Orca Codex usage and terminal Codex usage.
|
||||
- It matches what manual account switching already does today: logout/login mutates the same shared Codex state.
|
||||
|
||||
Tradeoff:
|
||||
|
||||
- Orca account switching will mutate the same Codex state used outside Orca.
|
||||
|
||||
That is acceptable for this product direction. Orca is acting as an automated frontend for the user's existing Codex environment, not a separate Codex silo.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Preserve the config sync patch
|
||||
|
||||
Keep the existing `config.toml` sync patch as a tactical fix while the broader migration is in progress.
|
||||
|
||||
### Phase 2: Introduce runtime-home owner
|
||||
|
||||
- Add a dedicated main-process runtime-home owner/service.
|
||||
- Route PTY spawning, rate-limit fetches, and login preparation through it.
|
||||
- Make `~/.codex` the explicit resolved runtime home for those flows.
|
||||
|
||||
### Phase 3: Move managed accounts to auth-only semantics
|
||||
|
||||
- Update account add/reauth logic to persist only the account's `auth.json`.
|
||||
- Stop treating per-account homes as full runtime environments.
|
||||
|
||||
### Phase 4: Account switch writes auth into shared runtime home
|
||||
|
||||
- On select, materialize the chosen account's `auth.json` into `~/.codex`.
|
||||
- Keep the existing restart notice flow for live Codex panes.
|
||||
|
||||
### Phase 5: Cleanup / compatibility
|
||||
|
||||
- Run the one-time legacy managed-home migration into `~/.codex`.
|
||||
- Mark old full-home-per-account storage as legacy.
|
||||
- Remove code paths that assume managed homes are full `CODEX_HOME`s.
|
||||
|
||||
## Risks
|
||||
|
||||
### Concurrent mixed-account sessions
|
||||
|
||||
This design assumes one selected active account at a time. If Orca ever needs simultaneous live sessions under different accounts, a single shared runtime home with one `auth.json` will not be sufficient.
|
||||
|
||||
### Unknown Codex coupling
|
||||
|
||||
We know `auth.json` is account-specific. We infer that most other files are environment/user-state and should be shared. If Codex later proves that some sqlite or cache files are also account-coupled, Orca may need to carve out a small additional per-account subset. That is still far simpler than syncing whole homes.
|
||||
|
||||
### Mutating the shared runtime home
|
||||
|
||||
This design intentionally updates `~/.codex/auth.json`. That means Orca and terminal Codex outside Orca share one Codex world. This is a deliberate product choice, not an accidental side effect.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit tests
|
||||
|
||||
- account selection writes the selected `auth.json` into `~/.codex`
|
||||
- PTY spawn uses `~/.codex` instead of the managed account home
|
||||
- config/history/session paths resolve from `~/.codex`
|
||||
- invalid or unreadable account auth does not corrupt the shared runtime home
|
||||
- startup with missing shared runtime files is repaired gracefully
|
||||
- rate-limit fetches and PTY launches consume the same runtime-home owner output
|
||||
|
||||
### Integration tests
|
||||
|
||||
- add account A, launch Codex, verify `~/.codex` gets history
|
||||
- switch to account B, restart pane, verify history remains available
|
||||
- verify `config.toml` and permissions do not change across account switches
|
||||
- verify only `auth.json` differs across account switches
|
||||
|
||||
### Manual verification
|
||||
|
||||
1. Start a Codex session under account A and create visible history.
|
||||
2. Switch to account B.
|
||||
3. Restart the Codex pane.
|
||||
4. Verify the session uses account B for auth/rate limits.
|
||||
5. Verify history, sessions, config, memories, and skills remain available.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Do `logs_2.sqlite` and `state_5.sqlite` behave correctly when fully shared across account switches? This is the expected design, but should be validated during rollout.
|
||||
- How should Orca expose refresh of the stored `system-default` auth snapshot when the user logs into Codex outside Orca?
|
||||
|
||||
## Recommendation
|
||||
|
||||
Adopt **`~/.codex` as the shared runtime `CODEX_HOME`, plus per-account `auth.json` only**, and implement it through a dedicated runtime-home owner in the main process.
|
||||
|
||||
This is the simplest design that:
|
||||
|
||||
- matches the intended account-switching UX,
|
||||
- avoids fragile replication logic,
|
||||
- works cross-platform,
|
||||
- and leaves room to adopt upstream Codex auth-profile support later if it becomes available.
|
||||
@@ -1,355 +0,0 @@
|
||||
# Engineering Spec: "Start from" field in Create Workspace
|
||||
|
||||
## Summary
|
||||
|
||||
Add a **"Start from"** field to the Create Workspace composer that lets the user pick a **branch or PR** as the basis for the new workspace. The picker is scoped to the selected repo; changing repo resets the field. This replaces no existing behavior: "Start from" defaults to the repo's **effective base ref** (`repo.worktreeBaseRef` when configured, otherwise the repo's detected default branch), so the current quick-create flow is unchanged.
|
||||
|
||||
This spec targets the shared composer flow, not just the modal wrapper. The quick-create modal and the full-page composer both consume `NewWorkspaceComposerCard` + `useComposerState`, so the new field/state should live there unless a piece is truly modal-only.
|
||||
|
||||
**Issues are out of scope for this picker.** Picking an issue does not change the base ref — it only sets `linkedIssue`, which the existing Link-work-item UI (`useComposerState.ts:256–260`) already handles. Putting Issues in a picker called "Start from" creates a second entry point into the same state and misleads the user. Users who want to link an issue use the existing Link UI.
|
||||
|
||||
## User-visible behavior
|
||||
|
||||
| Selection | Branch created from | Workspace metadata |
|
||||
|---|---|---|
|
||||
| Branch (default or other) | selected branch/ref | — |
|
||||
| PR | PR's same-repo head branch/ref | `linkedPR = #N` |
|
||||
|
||||
Important: Orca's create flow always creates a new worktree branch derived from the workspace name. "Start from PR" therefore means **branch from the PR head**, not "check out the PR branch directly".
|
||||
|
||||
**Scope for v1:** local repos support both picker tabs. Remote SSH repos support **Branches** only in this spec; PR start points stay disabled there until GitHub lookup can run without assuming a local `cwd`.
|
||||
|
||||
**PR scope for v1:** only PRs whose head branch lives in the selected repo are selectable. Fork PRs render disabled with copy like *"Fork PRs aren't supported yet in Start from"* because the current create flow cannot safely resolve a fork head from `headRefName` alone.
|
||||
|
||||
**Repo ↔ Start from contradiction:** the picker is repo-scoped. Changing `repoId` clears the prior selection and resets the field to the new repo's effective base ref. The field renders the reset inline (e.g. trigger reads *"Default branch — was PR #8778"*) instead of a fading toast, so the state is recoverable visually and not missed by users focused on another field.
|
||||
|
||||
**Naming:** when the picker selects a PR and the Name field is still auto-managed (matches `lastAutoNameRef.current`, including empty), apply the existing auto-name behavior from `getLinkedWorkItemSuggestedName()` to the actual `name` state. Once the user edits the name, subsequent PR selections leave `name` alone. This matches the existing Link-UI rule exactly — one code path, not two.
|
||||
|
||||
**Linked-work-item interaction:** the picker writes into the *same* `linkedWorkItem`/`linkedPR` state that the Link UI owns. A PR selection is a `linkedWorkItem` assignment. Switching back to a branch leaves `linkedWorkItem` alone: the user changed the *start ref*, not the *link*. If the user wants to remove the link, they use the Link UI. No source-tagging, no parallel state, no "whose selection was it" bookkeeping.
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
**No new shared type.** The "Start from" picker is a UI affordance that writes into fields the system already has:
|
||||
|
||||
- `CreateWorktreeArgs.baseBranch` (existing, `src/shared/types.ts:461`) — carries the resolved git ref the new worktree branches from.
|
||||
- `WorktreeMeta.linkedPR` (existing, `src/shared/types.ts:56`) — set on PR selection. `useComposerState` already owns `linkedWorkItem` + `linkedPR` state and already writes it via `applyWorktreeMeta` post-create (`useComposerState.ts:880`).
|
||||
|
||||
Every picker selection reduces to one of:
|
||||
|
||||
| Picker selection | `baseBranch` passed to create | Linked metadata |
|
||||
|---|---|---|
|
||||
| Branch, local row | short branch name (e.g. `main`) | — |
|
||||
| Branch, remote-tracking row | remote-qualified form from the row's full refname (e.g. `origin/main`, `upstream/feat-x`) | — |
|
||||
| PR #N (same-repo head) | `<remote>/<headRefName>` after main-process `git fetch` of that ref (see PR head resolution) | `linkedPR = N` |
|
||||
|
||||
Why full-ref precision without a new type: local and remote-tracking refs are not interchangeable. The picker emits the right short form to `baseBranch` — remote-tracking rows pass the `<remote>/<name>` form so the new branch tracks the remote ref instead of creating a detached HEAD. Classification at the picker must use the row's underlying full refname (`refs/heads/…` vs `refs/remotes/<remote>/…`), never prefix-matching a short name (a local branch literally named `origin/foo` is legal). The remote name comes from the row's full refname, not a hardcoded `origin` — repos can have `upstream` or other remotes.
|
||||
|
||||
**Scope note on main-side remote derivation.** `createRemoteWorktree` (`worktree-remote.ts:96`) and `createLocalWorktree` (`worktree-remote.ts:243`) currently derive the remote as `baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'`. For picker selections this is fine — remote-tracking rows and PR refs always contain a slash, so the derived remote is correct. Branch-picker rows without a slash are local heads, and the `'origin'` fallback is unused. Non-picker code paths that pass slashless remote refs still hit the `origin` hardcode; fixing that is *out of scope* for this spec. The `resolvePrBase` resolver (below) must derive the push remote explicitly, since the PR head may live on `upstream` or another remote configured at the repo level.
|
||||
|
||||
**PR head resolution lives in main, not the renderer.** `gh pr list` returns `headRefName` as a short branch name that typically does not exist locally. Passing the bare `headRefName` as `baseBranch` will fail `git worktree add` in the common case. Git-ref resolution (`git fetch`, `git rev-parse`) belongs in main; the renderer does not shell out.
|
||||
|
||||
New IPC: `worktrees:resolvePrBase`
|
||||
```ts
|
||||
window.api.worktrees.resolvePrBase({
|
||||
repoId,
|
||||
prNumber,
|
||||
// Optional cache hints from the renderer's existing PR cache.
|
||||
// When both are present, main skips the `gh pr view` lookup.
|
||||
headRefName?: string,
|
||||
isCrossRepository?: boolean,
|
||||
}) => Promise<{ baseBranch: string } | { error: string }>
|
||||
```
|
||||
On PR selection, the picker calls this resolver. Main:
|
||||
1. If both hints are present, skip GitHub lookup. Otherwise resolve the PR via the existing GitHub client to obtain `headRefName` + `isCrossRepository`.
|
||||
2. Reject fork PRs (`isCrossRepository === true`) with `"Fork PRs aren't supported yet"`.
|
||||
3. Runs `git fetch <remote> <headRefName>` against the repo's default remote (see Default remote selection below).
|
||||
4. Verifies the fetched ref with `git rev-parse --verify <remote>/<headRefName>`.
|
||||
5. Returns `{ baseBranch: "<remote>/<headRefName>" }` or a user-readable error.
|
||||
|
||||
Pre-submit resolution surfaces "branch deleted on remote" in the picker, not at create time. The resolved string is the one passed to `CreateWorktreeArgs.baseBranch` — no special `kind: 'pr'` at the IPC boundary.
|
||||
|
||||
**Concurrency / stale resolves.** A PR selection commits to `baseBranch` only after `resolvePrBase` succeeds. If the user selects a second PR before the first resolves, the first resolve's result is discarded (last-click-wins). Track this in the picker with a per-click token or `AbortController`; do not simply `await` sequentially, or late resolves will clobber newer selections.
|
||||
|
||||
**Submit while resolve pending.** If the user triggers create while a `resolvePrBase` is still in flight, the submit path waits for the pending resolve (or fails fast with a visible "Resolving PR head…" state). It must not submit with an unresolved `baseBranch` and it must not submit with the *previous* selection's `baseBranch`.
|
||||
|
||||
**Default remote selection.** "Default remote" is not "the one named `origin`." Resolve inside `resolvePrBase` in this order: (1) the remote configured on the repo's default branch (`git config branch.<default>.remote`); (2) `origin` if present; (3) the single remote if the repo has exactly one; (4) error otherwise (ask the user to configure). Centralize this in a helper in `src/main/git/repo.ts` rather than re-deriving per call site.
|
||||
|
||||
**WSL repos.** The existing main-process helpers route through `isWslPath` / `parseWslPath` (`src/main/ipc/worktree-remote.ts:19`). `worktrees:resolvePrBase` must use the same routing for its `git fetch` / `rev-parse` calls, not bare `gitExecFileAsync` on a raw path, or WSL repos will fail to resolve.
|
||||
|
||||
**Draft restore.** `newWorkspaceDraft.baseBranch` is persisted. On composer mount a restored `baseBranch` may reference a ref that no longer exists (PR closed, branch deleted since yesterday). Current main-process behavior will silently fall back to `worktreeBaseRef` / default branch rather than erroring — this is the same v1 hole tracked in Follow-up chores. Optional v1 nicety: run a cheap `rev-parse --verify` on mount (local repos only); if the ref is gone, clear `baseBranch` in the draft and show a one-line hint in the field ("Previous start ref no longer exists"). Don't block the user.
|
||||
|
||||
**Accessibility.** The popover must support keyboard-only operation: arrow keys within a tab, Tab / Shift+Tab between tabs, Enter to commit, Esc to close without committing. Reuse the existing Link-UI popover's keyboard hook rather than re-implementing.
|
||||
|
||||
### `GitHubWorkItem` additions (`src/shared/types.ts:390`)
|
||||
|
||||
One new field is needed. `GitHubWorkItem` already carries `branchName?: string` (`src/shared/types.ts:400`), which `src/main/github/client.ts:636` populates from `headRefName` for PR rows. **Reuse `branchName` — do not introduce a second field meaning "PR head branch".** Throughout this spec, reads of "the PR's head branch" on `GitHubWorkItem` refer to `branchName`.
|
||||
|
||||
```ts
|
||||
isCrossRepository?: boolean // true = fork PR; disabled in picker
|
||||
```
|
||||
|
||||
**Verification:** `gh pr list` in `src/main/github/client.ts:280,401,610` currently requests `number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName`. Add `headRepositoryOwner` to the field list at all three sites; compute `isCrossRepository` in the mapper as `item.headRepositoryOwner?.login !== <selected repo owner>`. Mapper change lands at `src/main/github/client.ts:636`, where `branchName` is already set — this is the one place fork detection is computed.
|
||||
|
||||
### Draft persistence (`src/renderer/src/store/slices/ui.ts:64`)
|
||||
|
||||
Extend `newWorkspaceDraft` with `baseBranch?: string`. Absence means "use the repo's effective base ref" — no `null`-plus-conversion step, shape matches `CreateWorktreeArgs.baseBranch`. `linkedPR` / `linkedWorkItem` are already persisted, so PR selections round-trip without further schema changes.
|
||||
|
||||
No new `worktrees:create` wire change. The existing contract already carries `baseBranch`.
|
||||
|
||||
---
|
||||
|
||||
## Main-process changes
|
||||
|
||||
### IPC handlers
|
||||
|
||||
- `src/main/ipc/worktrees.ts` — wire the new `worktrees:resolvePrBase` handler (signature + algorithm defined in §Data model). Handler module may live adjacent if it grows.
|
||||
- `src/main/ipc/worktree-remote.ts` — **no changes**. The existing `||` fallback chain is preserved as-is. The picker emits validated refs (branch rows from `searchBaseRefs` exist; PR refs are `git fetch`ed + `rev-parse`d inside `resolvePrBase` before the picker commits them to `baseBranch`), so in practice the picker never hands an unresolvable ref to create.
|
||||
|
||||
### Base-ref resolution
|
||||
|
||||
No main-process branching on "kind". The renderer collapses every picker selection into a `baseBranch` string (and, for PR selections, `linkedPR` metadata). The existing `args.baseBranch || repo.worktreeBaseRef || <detected default>` chain in `createLocalWorktree` / `createRemoteWorktree` is unchanged.
|
||||
|
||||
Known limitation (acceptable for v1): if a picker-selected ref is deleted between selection and submit, create silently falls back instead of erroring. In practice this requires the remote branch to disappear in the seconds between picker commit and Create click — vanishingly rare. Tightening this into strict-when-explicit behavior is tracked as a follow-up chore (see `Follow-up chores`).
|
||||
|
||||
### Metadata persistence
|
||||
|
||||
PR selections set `linkedPR` in the existing composer state. The existing post-create `applyWorktreeMeta` call (`useComposerState.ts:880`) already writes it — no new write path.
|
||||
|
||||
If metadata persistence fails after the git worktree already exists, log and continue. The worktree is still valid even if the link badge is missing.
|
||||
|
||||
### GitHub data scope
|
||||
|
||||
- local repos, PRs: existing `gh:listWorkItems` (cached — see caching rules below)
|
||||
- local repos, direct number lookup: existing `gh:workItem` (cached)
|
||||
- branches: existing `repos:searchBaseRefs`
|
||||
|
||||
No new GitHub IPC is required for local repos in v1.
|
||||
|
||||
### Create-time validation
|
||||
|
||||
Unchanged. The picker pre-validates refs (branches via `searchBaseRefs` results, PR heads via `resolvePrBase`'s fetch + `rev-parse`), so most bad paths are caught before submit. The one remaining hole — ref deleted between commit and create — falls through to today's silent fallback; see `Follow-up chores`.
|
||||
|
||||
---
|
||||
|
||||
## Caching rules (must read)
|
||||
|
||||
PR searches and number lookups hit the user's `gh` CLI quota. The picker **must** ride on the existing SWR caches in `src/renderer/src/store/slices/github.ts`; it must not introduce a parallel fetch path.
|
||||
|
||||
**Required behavior:**
|
||||
|
||||
- **Always call `fetchWorkItems(repoPath, limit, query, options?)`** — never `window.api.gh.listWorkItems(...)` directly. The store already deduplicates in-flight requests (`inflightWorkItemsRequests`) and applies `WORK_ITEMS_CACHE_TTL`. Direct calls bypass both.
|
||||
- **Use the prefetch path to warm shared keys.** The cache key is `(repoPath, limit, query)`. The picker's query **must** match the prefetch query exactly or cache hits won't share. Use:
|
||||
- Prefetch on composer mount (local repo): `prefetchWorkItems(repoPath, 36, 'is:pr is:open')`.
|
||||
- Picker PR tab default list: `fetchWorkItems(repoPath, 36, 'is:pr is:open')`.
|
||||
- Picker PR tab user query: `fetchWorkItems(repoPath, 36, \`is:pr is:open \${userQuery}\`)` — queries debounced ~150ms (matches existing Link-UI debounce) so rapid typing collapses to one fetch.
|
||||
- **Render cached results synchronously while revalidating.** Use `getCachedWorkItems(...)` for the first paint so opening the popover is instant and costs zero API calls when the cache is fresh.
|
||||
- **Direct-number lookup (`#123`, full URL) uses `gh:workItem` via its cache.** Same SWR contract; the picker reads `prCache`/`issueCache` synchronously first.
|
||||
- **Do not prefetch on every keystroke.** Only prefetch (a) on composer mount and (b) on popover open. Search queries go through the debounced `fetchWorkItems`, which dedupes against the cache anyway.
|
||||
- **PR resolver (`worktrees:resolvePrBase`) must reuse the renderer-side PR cache when available.** The renderer passes the already-known `headRefName` and `isCrossRepository` to the resolver (as an optional hint); main skips the `gh pr view` call when the hint is present. Only the `git fetch` + `git rev-parse` steps always run, since remote refs can change.
|
||||
- **Branch search (`repos:searchBaseRefs`) is git-local and cheap**; it does not count against GitHub quota, so fetch-on-demand when the Branches tab becomes active is acceptable. Debounce ~150ms to avoid redundant `git for-each-ref` invocations on large repos.
|
||||
|
||||
**Do not** call `prefetchWorkItems(repoPath, 'is:open')` — the second argument is `limit`, not `query`, and this would silently prefetch a different cache key than the picker reads.
|
||||
|
||||
**Audit existing prefetch callers.** `ui.ts:195` already calls `prefetchWorkItems(repo.path, 36, presetToQuery(preset))`. Confirm during implementation that at least one active task preset produces `'is:pr is:open'` (the exact string the picker queries) so the sidebar prefetch and the picker fetch share a cache key. If no preset matches exactly, add a dedicated mount-time prefetch in the composer and do not rely on the sidebar's opportunistic warming.
|
||||
|
||||
**Cache invalidation.** The existing SWR caches are keyed by `(repoPath, limit, query)` and expire via `WORK_ITEMS_CACHE_TTL`. The picker does **not** call `force: true` on every open — that defeats the cache. It only forces a refresh on explicit user action (e.g. a "Refresh" control in the popover, if added later). Stale-within-TTL is acceptable for picker use.
|
||||
|
||||
---
|
||||
|
||||
## Renderer changes
|
||||
|
||||
### 1. New components
|
||||
|
||||
- `src/renderer/src/components/new-workspace/StartFromField.tsx`
|
||||
- popover trigger (pill + title + chevron)
|
||||
- `src/renderer/src/components/new-workspace/StartFromPicker.tsx`
|
||||
- tabs: **Branches · Pull requests**
|
||||
- search input debounced ~150ms
|
||||
- PR tab calls `worktrees:resolvePrBase` on selection; shows an inline error on fetch/resolve failure *before* the user submits
|
||||
- on selection, calls back into `useComposerState` with `{ baseBranch, linkedWorkItem? }` — no new shared type
|
||||
|
||||
### 2. Popover state coverage
|
||||
|
||||
Each tab must render these states explicitly:
|
||||
|
||||
| Flow | Loading | Empty | Error | Success |
|
||||
|---|---|---|---|---|
|
||||
| Branches | skeleton rows | "No branches match" | inline error | list |
|
||||
| Pull requests | skeleton rows (only if no cached data) | "No open PRs" | "gh not available — Branches tab still works" | list (cached first, revalidated in background) |
|
||||
|
||||
Cached results must paint immediately; the loading state appears only when nothing is cached. This makes the common case a zero-API-cost open.
|
||||
|
||||
### 3. Integrate into shared composer state
|
||||
|
||||
Add `baseBranch?: string` state in `src/renderer/src/hooks/useComposerState.ts` (reusing the existing `linkedWorkItem` / `linkedPR` state for PR selections — don't introduce parallel `startFrom` state). The hook already owns:
|
||||
|
||||
- repo selection
|
||||
- `linkedWorkItem` + `linkedPR` (see `useComposerState.ts:209,223`)
|
||||
- auto-name behavior via `lastAutoNameRef` (`useComposerState.ts:262`)
|
||||
- full-page draft persistence
|
||||
- submit / submitQuick, with post-create `applyWorktreeMeta` already writing linked metadata (`useComposerState.ts:880`)
|
||||
|
||||
The modal wrapper should stay thin. `NewWorkspaceComposerModal.tsx` continues to pass through `cardProps` to `NewWorkspaceComposerCard`, while the card gets new props for rendering the field.
|
||||
|
||||
### 4. Picker data sources
|
||||
|
||||
- Branches tab:
|
||||
- `window.api.repos.searchBaseRefs({ repoId, query })`
|
||||
- PRs tab:
|
||||
- `fetchWorkItems(repoPath, 36, 'is:pr is:open')` for the default list
|
||||
- `fetchWorkItems(repoPath, 36, \`is:pr is:open \${userQuery}\`)` for typed queries
|
||||
- `getCachedWorkItems(...)` for first paint
|
||||
|
||||
Use the selected repo object already derived in `useComposerState`; do not introduce a separate `reposById` dependency unless the store actually gains one.
|
||||
|
||||
Filter PR results to same-repo heads only (`!isCrossRepository`). Fork PRs render disabled with explanatory copy, not silently filtered, so the user understands why their PR isn't selectable.
|
||||
|
||||
Normalize PR queries before dispatching GitHub lookups. Route by shape:
|
||||
|
||||
- bare number (`123`), `#123`, or a full GitHub PR URL for the selected repo → strip to the number and dispatch `gh:workItem` (reads `prCache` first per §Caching rules). `getWorkItem` returns `type: 'pr' | 'issue'`; when `type !== 'pr'`, treat as no-match in the PR tab (number collides with an issue).
|
||||
- full GitHub PR URL for a *different* repo → silently fall back to free-text search. Do not hard-block; users paste URLs because they want the content.
|
||||
- anything else → pass through as a free-text query to `fetchWorkItems(..., \`is:pr is:open \${query}\`)`.
|
||||
|
||||
### 5. Repo-change reset
|
||||
|
||||
On repo change:
|
||||
|
||||
- reset `baseBranch` to `undefined` (so the field shows the new repo's effective base ref as placeholder)
|
||||
- clear any transient picker state tied to the previous repo
|
||||
- the field's trigger copy shows the reset inline (e.g. *"Default branch — was PR #N"*) when a selection was cleared
|
||||
|
||||
The existing `handleRepoChange` callback (`useComposerState.ts:819`) already clears `linkedIssue` / `linkedPR` / `linkedWorkItem` inline; extend it to also clear `baseBranch`. One callback, not a new effect.
|
||||
|
||||
### 6. Naming behavior
|
||||
|
||||
When the picker selects a PR, it sets the existing `linkedWorkItem` state. The composer's existing auto-name path (which reacts to `linkedWorkItem` via `getLinkedWorkItemSuggestedName` and `lastAutoNameRef`) will update `name` iff `name === '' || name === lastAutoNameRef.current` — i.e. the name is still auto-managed. Once the user edits the name, subsequent selections leave it alone. This is the existing Link-UI rule; no new naming code.
|
||||
|
||||
### 7. Submission
|
||||
|
||||
Thread `baseBranch` through:
|
||||
|
||||
- `useComposerState` submit paths (already constructs `CreateWorktreeArgs` — just add the field)
|
||||
- persisted `newWorkspaceDraft`
|
||||
- store `createWorktree(...)` → preload `window.api.worktrees.create(...)` → main `CreateWorktreeArgs` (field already exists)
|
||||
|
||||
`linkedPR` needs no new wiring — the post-create `applyWorktreeMeta` call already writes it.
|
||||
|
||||
### 8. Prefetch
|
||||
|
||||
On composer mount (local repo only), warm the PR cache:
|
||||
|
||||
```ts
|
||||
prefetchWorkItems(repoPath, 36, 'is:pr is:open')
|
||||
```
|
||||
|
||||
Do **not** call `prefetchWorkItems(repoPath, 'is:open')`; the second argument is `limit`, not `query`. Do **not** use a query string that differs from what the picker will fetch — mismatched keys produce a double fetch.
|
||||
|
||||
Branch results stay fetch-on-demand when the Branches tab becomes active.
|
||||
|
||||
---
|
||||
|
||||
## Shortcut discoverability
|
||||
|
||||
Out of scope for this spec. A follow-up can add a split "+" button with `CmdOrCtrl+Shift+N` for a more explicit "Create from…" entry point.
|
||||
|
||||
## Explicitly out of scope
|
||||
|
||||
- **Issues tab.** Issues do not change the start ref; use the existing Link UI to link an issue.
|
||||
- **Checking out an existing branch without `-b`.** Orca's create flow always derives a new branch from the workspace name; "Start from PR" means branch from the PR head, not open the PR branch directly.
|
||||
- **Fork PR start points.** Disabled in v1.
|
||||
- **SSH PR start points.** Disabled in v1.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| User picks PR, then renames Name manually | Manual name wins (existing `lastAutoNameRef` rule) |
|
||||
| User picks PR, then picks a different PR without editing name | Name updates to the new PR's suggestion |
|
||||
| User picks PR from a fork | picker disables it in v1; no create attempt |
|
||||
| PR head branch has since been deleted at picker open | picker surfaces resolve error before submit; create never attempted |
|
||||
| PR head fetch fails (network/auth) | picker surfaces the fetch error; selection does not commit |
|
||||
| User picks branch, switches repo, switches back | no cross-repo picker state is preserved |
|
||||
| Offline / `gh` CLI missing | PRs tab shows error state; Branches tab still works |
|
||||
| Remote repo over SSH | only Branches tab is enabled in v1; PRs tab disabled with explanatory copy |
|
||||
| Repo has `worktreeBaseRef` set to non-default branch | reset behavior uses that configured base ref |
|
||||
| User pastes `#123` or a full GitHub PR URL | picker normalizes to the work item number and uses `gh:workItem` cache |
|
||||
| Pasted number resolves to an issue, not a PR, in the PR tab | treated as no-match; user sees empty-state copy |
|
||||
| User pastes a PR URL for a different repo | picker silently falls back to free-text search |
|
||||
| Selected ref (branch or PR head) disappears between picker commit and create | falls through to today's `worktreeBaseRef` / default-branch fallback (acceptable v1 hole; tracked in Follow-up chores) |
|
||||
| Restored draft references a ref that no longer exists | same silent fallback at create; optional v1 nicety clears the draft field with an inline hint on mount |
|
||||
| Popover opened with fresh cache | renders instantly from `getCachedWorkItems`; zero API calls |
|
||||
| User selects PR, then quickly selects a different PR before first resolve returns | last-click wins; stale resolve is discarded (AbortController / token) |
|
||||
| User hits Create while `resolvePrBase` is still pending | submit waits for the in-flight resolve; never submits with a stale `baseBranch` |
|
||||
| User rapidly toggles between Branches and PRs tabs mid-fetch | in-flight search requests for the prior tab are aborted; stale rows never render |
|
||||
|
||||
---
|
||||
|
||||
## Test plan
|
||||
|
||||
- **Unit / renderer**
|
||||
- `StartFromField` renders the correct pill for each selection kind
|
||||
- repo change resets `baseBranch` and the field shows the reset inline
|
||||
- full-page draft persistence round-trips `baseBranch` (and existing linked-work-item fields)
|
||||
- PR selection updates the actual `name` state only while it remains auto-managed (`name === '' || name === lastAutoNameRef.current`)
|
||||
- PR selection mirrors into linked-work-item state; switching back to branch does **not** clear the link
|
||||
- SSH repos disable PR tab
|
||||
- cross-repo PRs are disabled with explanatory copy
|
||||
- `#123` and full GitHub PR URLs normalize to number search
|
||||
- pasted cross-repo URLs fall back to free-text search (no hard error)
|
||||
- picker writes short name for local branch rows, `<remote>/<name>` for remote-tracking rows
|
||||
- PR selection calls `worktrees:resolvePrBase` and threads the resolved ref into `baseBranch`
|
||||
- **Caching**
|
||||
- opening the PR tab with a fresh cache triggers **zero** `window.api.gh.listWorkItems` calls (assert via spy)
|
||||
- prefetch on composer mount and picker default fetch share the same cache key (`(repoPath, 36, 'is:pr is:open')`)
|
||||
- rapid typing in the PR search debounces to a single fetch
|
||||
- direct-number lookup (`#123`) reads `prCache`/`issueCache` synchronously before hitting `gh:workItem`
|
||||
- `worktrees:resolvePrBase` skips `gh pr view` when the renderer passes a cached `headRefName` hint
|
||||
- rapid re-selection aborts prior `resolvePrBase`; only the latest selection's result commits
|
||||
- submit while `resolvePrBase` is pending waits for it; never submits a stale `baseBranch`
|
||||
- **Main-process**
|
||||
- `worktrees:resolvePrBase` fetches via the repo's default remote (not hardcoded `origin`), returns resolved ref on success
|
||||
- `worktrees:resolvePrBase` returns a user-readable error when the remote branch is missing or fetch fails
|
||||
- `worktrees:resolvePrBase` rejects fork PRs
|
||||
- `isCrossRepository` is populated on `GitHubWorkItem` PR rows from `headRepositoryOwner`
|
||||
- **Manual**
|
||||
- quick create without touching the field behaves exactly as today
|
||||
- create from same-repo PR creates a new branch from the PR head and shows the PR badge
|
||||
- SSH repo shows branch start points only
|
||||
- changing repo mid-flow resets the field and shows the inline reset copy
|
||||
- opening the picker a second time within the cache TTL makes zero network calls
|
||||
|
||||
---
|
||||
|
||||
## Rollout
|
||||
|
||||
Single PR. No feature flag. The change is additive and backward-compatible — `baseBranch` is already optional on `CreateWorktreeArgs`, and `linkedPR` metadata is already written by existing code.
|
||||
|
||||
## Files touched
|
||||
|
||||
```text
|
||||
src/shared/types.ts (add isCrossRepository to GitHubWorkItem; branchName already carries PR head)
|
||||
src/main/github/client.ts (add headRepositoryOwner to gh pr list field set; populate isCrossRepository)
|
||||
src/main/ipc/worktrees.ts (new worktrees:resolvePrBase handler)
|
||||
src/main/git/repo.ts (default-remote helper used by resolvePrBase)
|
||||
src/preload/index.ts (invoke worktrees:resolvePrBase)
|
||||
src/preload/api-types.d.ts (type worktrees.resolvePrBase)
|
||||
src/renderer/src/hooks/useComposerState.ts (baseBranch state + repo-change reset)
|
||||
src/renderer/src/components/NewWorkspaceComposerCard.tsx (render the field)
|
||||
src/renderer/src/components/new-workspace/StartFromField.tsx (new: pill trigger)
|
||||
src/renderer/src/components/new-workspace/StartFromPicker.tsx (new: tabs + picker; SWR via fetchWorkItems/getCachedWorkItems)
|
||||
src/renderer/src/store/slices/ui.ts (baseBranch in newWorkspaceDraft)
|
||||
src/renderer/src/lib/new-workspace.ts (if a small display helper is needed)
|
||||
```
|
||||
|
||||
Estimated effort: ~1.5 engineering days — new `worktrees:resolvePrBase` IPC, `isCrossRepository` plumbing through the `gh` client and its mappers, picker + cancellation, URL normalization, prefetch key alignment, and renderer + main tests. Fully additive; no behavior change for existing flows.
|
||||
|
||||
## Follow-up chores
|
||||
|
||||
File these as separate issues at ship time, not in this PR:
|
||||
|
||||
- **Strict-when-explicit base-ref validation.** Today `createLocalWorktree` / `createRemoteWorktree` silently fall back when an explicit `args.baseBranch` is unresolvable. The Start-from picker avoids this in practice by pre-validating, but a ref deleted between selection and submit still falls through. Replace the `||` chain with strict-when-explicit / fallback-when-implicit; add `SshGitProvider.verifyRef` for the remote path. Standalone refactor, own test coverage, affects all callers of `CreateWorktreeArgs.baseBranch`.
|
||||
- **Main-side default-remote derivation.** `createLocalWorktree` / `createRemoteWorktree` derive remote as `origin` when `baseBranch` lacks a slash. Centralize default-remote resolution (push remote of default branch → `origin` → single remote) and share with `resolvePrBase`.
|
||||
@@ -1,143 +0,0 @@
|
||||
# Design: Searchable Repository Selection in New Worktree Dialog
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
GitHub issue [#379](https://github.com/stablyai/orca/issues/379): when creating a new worktree, the user must manually scroll a plain `Select` dropdown to pick the target repository. This does not scale once Orca manages many repositories.
|
||||
|
||||
The current Radix `Select` in `AddWorktreeDialog` has two product problems:
|
||||
|
||||
- It forces serial scanning instead of direct search.
|
||||
- It makes the most important first step in the worktree-creation flow slower than every subsequent step.
|
||||
|
||||
The issue has no explicit acceptance criteria, so implementation requirements are inferred from the existing UX:
|
||||
|
||||
- Users must be able to search for a repository by name while creating a worktree.
|
||||
- Existing auto-preselection behavior (preselectedRepoId, activeWorktreeRepoId, activeRepoId fallback chain) must keep working.
|
||||
- Keyboard and mouse selection must both work.
|
||||
- The change must not break the rest of the create-worktree flow: setup lookup, issue-command lookup, validation, and Enter-to-create behavior.
|
||||
|
||||
## 2. Approach
|
||||
|
||||
Replace the repository `Select` with a searchable combobox built from Orca's existing `Command` primitive (cmdk-based) and a new shared `Popover` primitive (Radix-based).
|
||||
|
||||
Rationale:
|
||||
|
||||
- A combobox is the standard pattern for "pick one item from a potentially long list, with search."
|
||||
- Orca already ships `cmdk`-based command surfaces and Radix primitives, so this fits the existing stack without introducing new dependencies.
|
||||
- Keeping the search scoped to the repo field preserves the current worktree-creation layout instead of turning the entire dialog into a command palette.
|
||||
- Search logic lives in a pure helper (`searchRepos`) so matching rules are explicit and regression-testable outside React.
|
||||
|
||||
### Matching and ranking
|
||||
|
||||
- Search is case-insensitive substring matching.
|
||||
- Primary target is `repo.displayName` (what issue #379 is specifically about). Secondary target is `repo.path` for disambiguating repos with similar display names.
|
||||
- Ranking uses position-based scoring: matches earlier in the string rank higher. Display-name matches always outrank path-only matches (path scores are offset by 1000). Ties preserve original list order.
|
||||
- Empty query returns the full eligible repo list in its original order.
|
||||
|
||||
## 3. Implementation Plan
|
||||
|
||||
### `src/renderer/src/components/ui/popover.tsx`
|
||||
|
||||
New shared Popover wrapper around Radix `Popover`. Follows the same styling conventions as existing `dialog.tsx`, `select.tsx`, and `dropdown-menu.tsx`. Generic and reusable — not inlined inside the worktree dialog.
|
||||
|
||||
### `src/renderer/src/lib/repo-search.ts`
|
||||
|
||||
New pure helper (`searchRepos`) that filters and ranks eligible repositories by a query string. Normalizes trimming and lowercasing in one place. Scoring logic:
|
||||
|
||||
- Display-name hit: score = index of substring match within `displayName`.
|
||||
- Path-only hit: score = 1000 + index of substring match within `path`.
|
||||
- No hit: excluded from results.
|
||||
- Stable sort by `(score, originalIndex)` so equivalent matches preserve list order.
|
||||
|
||||
### `src/renderer/src/lib/repo-search.test.ts`
|
||||
|
||||
Unit tests covering:
|
||||
|
||||
- Empty query returns all repos in original order.
|
||||
- Display-name matching is case-insensitive.
|
||||
- Path fallback works when the display name does not match.
|
||||
- Display-name matches rank ahead of path-only matches.
|
||||
|
||||
### `src/renderer/src/components/repo/RepoCombobox.tsx`
|
||||
|
||||
New reusable combobox component. Props: `repos`, `value`, `onValueChange`, optional `placeholder`.
|
||||
|
||||
Key design decisions:
|
||||
|
||||
- **External filtering**: `Command` is rendered with `shouldFilter={false}` because filtering is handled by `searchRepos`, not by cmdk's built-in filter. This keeps ranking rules in the pure helper where they are testable.
|
||||
- **Trigger and content markup**: Both the trigger `Button` and `PopoverContent` carry `data-repo-combobox-root="true"` so the parent dialog's keydown handler can detect events originating inside the combobox surface (see Section 5).
|
||||
- **Selected repo display**: The trigger renders the selected repo using the existing `RepoDotLabel` component, consistent with how repos appear elsewhere in Orca.
|
||||
- **Result items**: Each item shows `RepoDotLabel` for the display name plus the full `repo.path` as secondary text, so repos with similar names are visually distinguishable.
|
||||
- **Popover width**: `PopoverContent` uses `w-[var(--radix-popover-trigger-width)]` to match the trigger width for visual alignment.
|
||||
- **Focus management**: `CommandInput` has `autoFocus` so the search input receives focus immediately when the popover opens.
|
||||
- **Query reset on close**: `handleOpenChange` clears the query when the popover closes so stale filter text from a previous interaction does not hide repos on the next open.
|
||||
- **Empty state**: `CommandEmpty` shows an explicit message when no repo matches.
|
||||
|
||||
### `src/renderer/src/components/sidebar/AddWorktreeDialog.tsx`
|
||||
|
||||
Changes to the existing dialog:
|
||||
|
||||
- Replace the `Select` repository control with `RepoCombobox`.
|
||||
- All existing repo-selection state and side effects are preserved unchanged: `repoId`, `handleRepoChange`, auto-preselection on open, hook lookup, and issue-command lookup.
|
||||
- Update the dialog-level `handleKeyDown` to suppress Enter when the event target lives inside a `[data-repo-combobox-root="true"]` element (see Section 5).
|
||||
|
||||
## 4. Edge Cases
|
||||
|
||||
| Scenario | Handling |
|
||||
|---|---|
|
||||
| No eligible git repos | Existing guard closes the dialog; the combobox is never rendered in a broken state. |
|
||||
| Only one eligible repo | The combobox still renders normally. Search is trivial, but the UI stays consistent. |
|
||||
| Multiple repos share a similar display name | Search also matches `repo.path`, and each item shows the full path as secondary text for disambiguation. |
|
||||
| User opens combobox, types a query, closes, reopens | Query resets on close so the next open starts from the full list. |
|
||||
| Enter pressed while focus is inside the repo search input | The dialog-level create shortcut is suppressed for events originating inside the combobox (see Section 5). |
|
||||
| Preselected repo is not the first repo in the list | Auto-preselection logic is unchanged; the selected value is still controlled by `repoId` state. |
|
||||
| Repo list changes while the dialog is open | The combobox renders from the live `eligibleRepos` array and updates reactively. If the selected repo disappears, existing dialog validation prevents create without a valid `selectedRepo`. |
|
||||
|
||||
## 5. Regressions and Mitigations
|
||||
|
||||
### Enter inside repo search creates a worktree
|
||||
|
||||
This is the most important correctness constraint. Without mitigation, typing a search query and pressing Enter to select a repo would bubble up to the dialog's keydown handler and trigger worktree creation.
|
||||
|
||||
Mitigation: `data-repo-combobox-root="true"` is placed on both the combobox trigger button and the popover content. The dialog's `handleKeyDown` checks `e.target.closest('[data-repo-combobox-root="true"]')` and returns early when it finds a match. This covers Enter events from:
|
||||
|
||||
- The `CommandInput` search field (inside the popover content).
|
||||
- The trigger button itself when the popover is closed (Enter should open the popover, not submit the form).
|
||||
|
||||
An inline comment in `handleKeyDown` documents why this guard exists.
|
||||
|
||||
### Stale search query hides repos on reopen
|
||||
|
||||
Mitigation: `RepoCombobox.handleOpenChange` resets the query to `''` whenever the popover closes. An inline comment documents the timing interaction with the dialog's own delayed field reset.
|
||||
|
||||
### Repo preselection breaks
|
||||
|
||||
Mitigation: `repoId` remains the single source of truth for the selected repository. Only the presentation control was swapped; the state management and auto-selection logic in `AddWorktreeDialog` are untouched.
|
||||
|
||||
### Users cannot distinguish similarly named repos
|
||||
|
||||
Mitigation: Each combobox item shows both the `RepoDotLabel` display name and the full `repo.path` as secondary text.
|
||||
|
||||
### Matching behavior drifts over time
|
||||
|
||||
Mitigation: Search rules live in the pure `searchRepos` helper with dedicated unit tests, not inlined in JSX.
|
||||
|
||||
## 6. Test Plan
|
||||
|
||||
### Unit tests (`repo-search.test.ts`)
|
||||
|
||||
- Empty query returns all repos in original order.
|
||||
- Display-name matching is case-insensitive.
|
||||
- Path fallback works when the display name does not match.
|
||||
- Display-name matches rank ahead of path-only matches.
|
||||
|
||||
### Manual verification
|
||||
|
||||
- Open the New Worktree dialog with multiple repos.
|
||||
- Confirm the repo field opens a searchable combobox instead of a plain dropdown.
|
||||
- Type part of a repo name and verify the list filters immediately.
|
||||
- Press Enter to select a filtered repo and verify the dialog does not submit.
|
||||
- Create a worktree after selecting a repo through search and verify the existing create flow still works end-to-end (setup, issue command, metadata).
|
||||
- Reopen the dialog and verify the previous search text is cleared.
|
||||
- Verify the preselected repo still appears when opening from flows that pass `preselectedRepoId`.
|
||||
- Verify keyboard navigation (arrow keys, Enter to select, Escape to close popover) works within the combobox.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user