mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
WIP: smart worktree order redesign
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
# Smart worktree order — redesign
|
||||
|
||||
## Problem with the current heuristic
|
||||
|
||||
The current "smart" sort (`src/renderer/src/components/sidebar/smart-sort.ts`) is a single weighted sum of seven signals:
|
||||
|
||||
| Signal | Weight |
|
||||
| ------------------ | -----: |
|
||||
| Running AI job | +60 |
|
||||
| Recent activity | +36 (decays over 24h) |
|
||||
| Needs attention | +35 |
|
||||
| Unread | +18 |
|
||||
| Open terminal | +12 |
|
||||
| Live branch PR | +10 |
|
||||
| Linked issue | +6 |
|
||||
|
||||
Weighted sums are the classic heuristic failure mode:
|
||||
|
||||
- **Not predictable.** Users can't tell why X is above Y; the answer is "because of an arithmetic combination of seven hidden numbers."
|
||||
- **Fragile under change.** Every new signal forces a re-tune of all existing weights to avoid regressions. The codebase already has comments justifying weight collisions (e.g. why "36" must exceed `18 + 10 + 6 = 34`).
|
||||
- **Tests pin numerical scores** (`expect(...).toBe(12)`, `>=35`) rather than ordering invariants — refactors are scary.
|
||||
- **Conflates user activity with agent activity.** "Recent" already exists for user activity. "Smart" should be answering a different question.
|
||||
|
||||
## Context: agent status is default-on, and persists across restart
|
||||
|
||||
Two upstream changes make hook-reported agent state a reliable primary signal:
|
||||
|
||||
1. **Default-on.** The `experimentalAgentDashboard` flag is removed (PR #1538, `5a2a32b6`). Every worktree has a real, hook-reported agent lifecycle from `src/shared/agent-status-types.ts`:
|
||||
|
||||
- `working` / `blocked` / `waiting` / `done`
|
||||
- `stateStartedAt` (when the current state began)
|
||||
- `interrupted` (`done` was a Ctrl+C)
|
||||
- `stateHistory[]` (rolling log of prior states; renderer-only)
|
||||
|
||||
2. **Persisted across restart.** The hook server's per-pane status cache (`lastStatusByPaneKey`) is persisted to `userData/agent-hooks/last-status.json` and rehydrated on launch. After relaunch, every pane's last-known `state` and `stateStartedAt` are restored before any new hook event arrives. Smart sort sees the same agent state it saw at quit.
|
||||
|
||||
Caveat: only the **current** entry is persisted (on-disk shape carries `receivedAt`/`stateStartedAt` alongside the `ParsedAgentStatusPayload`; restored via `AgentStatusIpcPayload` on the IPC, consumed via `setAgentStatus`'s 4th `timing` arg). The renderer's per-pane `stateHistory[]` is rebuilt from live IPC and starts empty after a restart. This affects exactly one case the redesign cares about — see "Cold start after restart" under Edge cases.
|
||||
|
||||
**Sequencing:** the persistence work lands in a separate change (sibling branch `agent-status-preserve-restart`). The smart-sort code can ship before persistence is merged, but the "Smart sees pre-quit state on restart" guarantee only holds once persistence is in. Without persistence, every cold start drops every worktree to Class 4 until each agent's next live hook event repopulates `agentStatusByPaneKey`. Acceptable as an interim state; document it in release notes.
|
||||
|
||||
This is a much better signal than terminal-title heuristics. The redesign leans on it as the *primary* ordering signal, not one of seven weighted ones.
|
||||
|
||||
---
|
||||
|
||||
## The design: "Smart" = status-class first, recency-of-attention second
|
||||
|
||||
`Smart` orders by two keys: a strict ordinal class derived from the agent's *current* state, and within each class, the timestamp of the last attention event.
|
||||
|
||||
### Why two keys, not one
|
||||
|
||||
A pure-recency design (single timestamp axis) lets a `done` worktree outrank a `blocked` worktree if the `done` is 30s newer. With 8+ concurrent agents, that's how users miss a permission prompt. The fix is ~5 LOC: add an ordinal class above the timestamp.
|
||||
|
||||
This preserves the "one explainable rule" property — "X is above Y because of state class first, then recency" is still a sentence a user can predict and verify — while keeping urgency invariants. `blocked` can never be outranked by `done`. Working agents stop fighting for the top.
|
||||
|
||||
### The class
|
||||
|
||||
Each worktree resolves to one of four classes based on its agents' current state (most attention-demanding pane wins — see "Multiple panes" below):
|
||||
|
||||
| Class | Members | Why |
|
||||
|------|--------|----|
|
||||
| 1 — Needs you | `blocked`, `waiting` | Agent is stuck on the user. Highest priority. |
|
||||
| 2 — Done | `done` (not `interrupted`) | Output is ready to read. |
|
||||
| 3 — Working | `working` | Agent is mid-step; don't interrupt. |
|
||||
| 4 — Idle | no live agent state, or stale (`> AGENT_STATUS_STALE_AFTER_MS`), or `done` + `interrupted` | Treat the same as worktrees that never had an agent. |
|
||||
|
||||
`interrupted` `done` (user hit Ctrl+C) is treated as idle, not as Class 2 — interrupting was the user's signal that this turn no longer needs attention.
|
||||
|
||||
### The within-class timestamp
|
||||
|
||||
Within each class, the timestamp that drives recency depends on the class:
|
||||
|
||||
- Class 1 / 2: `stateStartedAt` of the **current** entry. This is the moment the agent entered the attention state.
|
||||
- Class 3 (working): `stateStartedAt` of the **most recent prior** `done`/`blocked`/`waiting` from the renderer's `stateHistory[]`. If history is empty (e.g. fresh after restart), fall back to `stateStartedAt` of the current `working` entry — the agent has been working since then, with no recorded prior attention event. We do not freshness-check history entries — within-class ordering is comparative, so old timestamps cannot leak across class boundaries (class is set by the *current* entry, which IS freshness-checked).
|
||||
- Class 4 (idle): `effectiveRecentActivity(worktree, now)` — falls back to `lastActivityAt` with the existing `CREATE_GRACE_MS` floor for new worktrees.
|
||||
|
||||
We call this resolved timestamp `attentionTimestamp` per worktree.
|
||||
|
||||
### The comparator
|
||||
|
||||
```
|
||||
sortBy: 'smart'
|
||||
→ class asc (1 = Needs you, 4 = Idle)
|
||||
→ attentionTimestamp desc (within-class recency)
|
||||
→ effectiveRecentActivity desc (final tiebreaker for idle worktrees)
|
||||
→ displayName asc (last-resort tiebreaker)
|
||||
```
|
||||
|
||||
Two keys, three tiebreakers.
|
||||
|
||||
### What happens to today's seven signals
|
||||
|
||||
| Signal | Today | New design |
|
||||
| --------------------- | ------ | --------------------------------------------------------------------------------------------------------- |
|
||||
| Running AI job | +60 | **Removed from sort.** `working` is Class 3 — below `done` and `blocked`. Still shown as a dot on the card. |
|
||||
| Recent activity | +36 | **Last-resort tiebreaker** for idle worktrees. Agent class dominates. |
|
||||
| Needs attention | +35 | **This *is* Class 1** (`blocked`/`waiting`) — the top of the sort. |
|
||||
| Unread | +18 | **Removed from sort.** Already shown as a badge. |
|
||||
| Open terminal | +12 | **Removed from sort.** Already implied by "Active only" filter. |
|
||||
| Live branch PR | +10 | **Removed from sort.** Already shown on the card and groupable via `pr-status`. |
|
||||
| Linked issue | +6 | **Removed from sort.** Already shown on the card. |
|
||||
|
||||
The card decoration stays — we're not removing visual signals, only their effect on order.
|
||||
|
||||
---
|
||||
|
||||
## Why this is the right shape
|
||||
|
||||
- **One rule, two clauses, both explainable.** "X is above Y because X is in a more attention-demanding class than Y, and within the same class because X's agent demanded attention more recently." A user can predict and verify both clauses from visible state (the dot tells them the class; ordering within class is recency).
|
||||
- **Urgency invariants hold.** A `blocked` worktree is never outranked by a fresher `done`. A `done` worktree is never outranked by a `working` one. Class is strict ordinal, so visual state and rank position never contradict each other.
|
||||
- **Aligned with the user's actual job.** When you have eight worktrees with eight agents running, the question you want answered is "which ones need me right now, and in what order did they need me?" Not "which one had the most recent OS-level PTY byte."
|
||||
- **Working agents stop fighting for the top.** Today, every running agent in a 10-worktree project competes for the top slot via `+60`. Under this design, a working agent sits in Class 3 below all `done`/`blocked` worktrees — it stays out of the way until it needs you, at which point it jumps into Class 1 or 2 with a clear reason.
|
||||
- **Tests can assert orderings, not numbers.** `expect(after('blocked')).toRankAbove(after('done'))` and `expect(within('done').newer).toRankAbove(within('done').older)` are meaningful invariants. `expect(score).toBe(12)` is not.
|
||||
- **Lean on the new default-on, persisted-across-restart signal.** Agent state is reliable for every user *and* survives quit/relaunch (per "Context" above). The class layer reads `agentStatusByPaneKey` directly, which is hydrated before sort runs.
|
||||
|
||||
## Edge cases
|
||||
|
||||
1. **Worktree with no agent ever.** No live entry → Class 4 (idle). Sorts below all classes, then by `effectiveRecentActivity`. Same effective behavior as today's "Recent" within the idle group.
|
||||
2. **Agent transitions `done` → `working`.** Class flips from 2 to 3. The worktree drops below all `done`/`blocked` worktrees. *Within Class 3*, the prior `done` timestamp from `stateHistory[]` drives rank, so it sits at the top of the working group — i.e., "it's the most recently-relevant working agent." This is intentional: once the user has new work running, the *category* of attention has changed, and the visible dot agrees with the position.
|
||||
3. **User views the worktree.** Class and timestamp do not change on view. The worktree stays where it was. We do not auto-demote on view in v1 — see Open Question #4.
|
||||
4. **Agent goes `blocked` → `working` → `blocked` quickly.** Each `blocked` transition makes the worktree Class 1 with a fresh `stateStartedAt`. It stays at the top through the back-and-forth, which is correct.
|
||||
5. **Multiple panes per worktree.** Class is the **min** across all panes (most-attention-demanding wins — Class 1 < 2 < 3 < 4). `attentionTimestamp` is the **max** across panes within the resolved class. Any pane that's blocked pulls the whole worktree to Class 1; among Class-1 worktrees, the most-recent `blocked` event ranks first.
|
||||
6. **Brand-new worktree with no agent yet.** Class 4 (idle). The existing `CREATE_GRACE_MS` floor on `effectiveRecentActivity` keeps it at the top of the idle group during the post-create window. Already covered today.
|
||||
7. **Stale agent entries.** An entry whose `updatedAt` is older than `AGENT_STATUS_STALE_AFTER_MS` (30 min) is treated as if no live entry exists — the worktree falls to Class 4. Matches existing freshness logic in `isExplicitAgentStatusFresh`.
|
||||
8. **Cold start after restart.** Hook server hydrates `lastStatusByPaneKey` from disk before binding the listener; the renderer pulls a snapshot when settings + workspace tabs are ready. Before that snapshot arrives, every worktree is Class 4 — Smart momentarily falls back to `effectiveRecentActivity` ordering (the same key the `recent` sort uses), then re-ranks classes 1/2/3 as soon as the snapshot lands. The snapshot restores `state` and `stateStartedAt` per pane, so Classes 1/2 and current-state-driven Class 3 timestamps work normally. The bootstrap delivers N pane entries through N `setAgentStatus` calls (one sortEpoch bump each); the existing `SORT_SETTLE_MS` debounce in `WorktreeList` coalesces them into a single re-sort.
|
||||
|
||||
The one *narrow* gap: `stateHistory[]` is renderer-only, not persisted. After restart, a pane that's *currently* `working` cannot recover its prior `done`/`blocked` timestamp from before quit — Class 3's within-class timestamp falls back to the current `working` `stateStartedAt`. In practice this only matters when (a) the agent was working at quit time *and* (b) had a prior attention event the user wants reflected in rank. The far-more-common cases — `done`/`blocked` at quit, or `working` agent that did its first attention event in this session — are unaffected.
|
||||
9. **Agents without hooks (aider, custom scripts).** Class is derived from explicit hook state only. Agents that don't ship hooks land in Class 4 — same as no-agent worktrees. The terminal-title heuristic permission detector is *not* extended into the new sort: Open Question #3 below.
|
||||
|
||||
## Implementation sketch
|
||||
|
||||
### Files touched
|
||||
|
||||
Primary:
|
||||
- `src/renderer/src/components/sidebar/smart-sort.ts` — comparator rewrite, function deletions
|
||||
- `src/renderer/src/components/sidebar/smart-attention.ts` — **new file**: `SmartClass`, `WorktreeAttention`, `resolveAttention`, `buildAttentionByWorktree`, `mostRecentAttentionInHistory`
|
||||
- `src/renderer/src/components/sidebar/smart-sort.test.ts` — replace numerical assertions with ordering invariants
|
||||
- `src/renderer/src/components/sidebar/smart-attention.test.ts` — **new file**: helper-level tests
|
||||
- `src/renderer/src/components/WorktreeJumpPalette.tsx` — thread `agentStatusByPaneKey` at lines 227 and 239
|
||||
|
||||
No changes:
|
||||
- `src/shared/agent-status-types.ts` — already provides `AgentStatusEntry`, `AgentStateHistoryEntry.startedAt`, `AgentStateHistoryEntry.interrupted`, `AGENT_STATUS_STALE_AFTER_MS`, `AGENT_STATE_HISTORY_MAX`.
|
||||
- `src/renderer/src/store/slices/agent-status.ts` — already preserves `interrupted` on `stateHistory` rows (the helper relies on this).
|
||||
- `src/renderer/src/lib/agent-status.ts` — `isExplicitAgentStatusFresh` is reused as-is.
|
||||
- `src/main/agent-hooks/server.ts` — persistence is a separate change on the sibling branch.
|
||||
|
||||
### Data model
|
||||
|
||||
No new persisted fields on the worktree. Class and `attentionTimestamp` are derived at sort time from `agentStatusByPaneKey` (the renderer slice that already aggregates per-pane explicit status). The slice is hydrated on launch from the persisted hook-server cache (see "Context"), so cold-start sorting reflects pre-quit state once the snapshot arrives.
|
||||
|
||||
```ts
|
||||
type SmartClass = 1 | 2 | 3 | 4 // 1=NeedsYou, 2=Done, 3=Working, 4=Idle
|
||||
|
||||
type WorktreeAttention = {
|
||||
cls: SmartClass
|
||||
attentionTimestamp: number // 0 when class === 4
|
||||
}
|
||||
```
|
||||
|
||||
### Per-worktree resolution
|
||||
|
||||
For each worktree, walk its panes' entries in `agentStatusByPaneKey`:
|
||||
|
||||
```ts
|
||||
function resolveAttention(
|
||||
panes: AgentStatusEntry[], // entries belonging to this worktree's panes
|
||||
now: number
|
||||
): WorktreeAttention {
|
||||
let bestCls: SmartClass = 4
|
||||
let bestTs = 0
|
||||
|
||||
for (const entry of panes) {
|
||||
if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) continue
|
||||
|
||||
let cls: SmartClass
|
||||
let ts: number
|
||||
if (entry.state === 'blocked' || entry.state === 'waiting') {
|
||||
cls = 1
|
||||
ts = entry.stateStartedAt
|
||||
} else if (entry.state === 'done') {
|
||||
// Why: an interrupted `done` (user pressed Ctrl+C) is the user signalling
|
||||
// "I'm done with this turn". Treat as idle, not as Class 2 attention.
|
||||
if (entry.interrupted) continue
|
||||
cls = 2
|
||||
ts = entry.stateStartedAt
|
||||
} else {
|
||||
// working
|
||||
cls = 3
|
||||
// Why: within the working class, sort by the most-recent prior attention
|
||||
// event so a worktree that just transitioned done→working stays above one
|
||||
// that's been working for an hour. Falls back to current stateStartedAt
|
||||
// when stateHistory is empty (e.g. fresh after restart — see Edge case 8).
|
||||
const prior = mostRecentAttentionInHistory(entry.stateHistory)
|
||||
ts = prior ?? entry.stateStartedAt
|
||||
}
|
||||
|
||||
// Why min: smaller class number = higher priority. Any pane in a more
|
||||
// attention-demanding class promotes the whole worktree.
|
||||
if (cls < bestCls || (cls === bestCls && ts > bestTs)) {
|
||||
bestCls = cls
|
||||
bestTs = ts
|
||||
}
|
||||
}
|
||||
|
||||
return { cls: bestCls, attentionTimestamp: bestTs }
|
||||
}
|
||||
|
||||
function mostRecentAttentionInHistory(history: AgentStateHistoryEntry[]): number | null {
|
||||
let max = 0
|
||||
for (const h of history) {
|
||||
// Why this works: `setAgentStatus` in `agent-status.ts` already pushes
|
||||
// `interrupted` onto history rows when an interrupted `done` transitions
|
||||
// out, so we see it on historical entries the same way as on the current.
|
||||
if (h.state === 'done' && h.interrupted) continue
|
||||
if (h.state === 'done' || h.state === 'blocked' || h.state === 'waiting') {
|
||||
if (h.startedAt > max) max = h.startedAt
|
||||
}
|
||||
}
|
||||
return max > 0 ? max : null
|
||||
}
|
||||
```
|
||||
|
||||
`stateHistory` is bounded at `AGENT_STATE_HISTORY_MAX = 20` — if an agent ping-pongs through 20+ working/done cycles inside one session, the very-oldest attention event scrolls off and the worktree's Class-3 timestamp will refresh to the next-oldest still in history. Acceptable tradeoff; the alternative is unbounded history per pane.
|
||||
|
||||
### Build-once-per-sort optimization
|
||||
|
||||
`agentStatusByPaneKey` is keyed by `${tabId}:${paneId}`. Build a `tabId → entries[]` index once per sort (the existing `buildExplicitEntriesByTabId` helper does this), then walk each worktree's tabs and accumulate. The aggregate cost is `O(E + N × T × H)` per sort, where E = total entries, N = worktrees, T = tabs/worktree, H = history length. With H bounded at 20 and typical T ≤ 4, this is comfortably below the existing decorate-sort-undecorate path.
|
||||
|
||||
### The comparator
|
||||
|
||||
```ts
|
||||
function buildSmartComparator(
|
||||
attentionByWorktree: Map<string, WorktreeAttention>,
|
||||
now: number
|
||||
) {
|
||||
return (a: Worktree, b: Worktree) => {
|
||||
const aw = attentionByWorktree.get(a.id) ?? IDLE
|
||||
const bw = attentionByWorktree.get(b.id) ?? IDLE
|
||||
return (
|
||||
aw.cls - bw.cls || // 1 < 2 < 3 < 4
|
||||
bw.attentionTimestamp - aw.attentionTimestamp ||
|
||||
effectiveRecentActivity(b, now) - effectiveRecentActivity(a, now) ||
|
||||
a.displayName.localeCompare(b.displayName)
|
||||
)
|
||||
}
|
||||
}
|
||||
const IDLE: WorktreeAttention = { cls: 4, attentionTimestamp: 0 }
|
||||
```
|
||||
|
||||
### Stable rank during interaction
|
||||
|
||||
The current implementation uses `precomputedScores` to keep `Array.sort` stable across the comparator's O(N log N) calls. The new design preserves this: build `attentionByWorktree` once before sort and pass it into the comparator. No score-snapshot indirection (`SmartSortOverride`) is needed — the resolved class/timestamp is already a frozen pre-sort decoration.
|
||||
|
||||
For the *currently-focused* worktree specifically, we do not freeze its position. If the user's active worktree transitions class while they're interacting, it will move on the next re-sort. This is consistent with today's behavior. If we later find users want "stable rank for the focused tab during a single editing session," it's a separate, bounded change.
|
||||
|
||||
### Code to delete
|
||||
|
||||
- `computeSmartScore`, `computeSmartScoreFromSignals`
|
||||
- `hasRecentPRSignal` (caller in smart-sort only; PR cache stays for grouping)
|
||||
- `SmartSortOverride` and the `smartSortOverrides` parameter — overrides existed to freeze decay-based scores; class/timestamp don't decay between events
|
||||
- `precomputedScores` parameter (replaced by `attentionByWorktree`)
|
||||
- `CREATE_GRACE_MS` *override* path stays for `recent`, and is reused as the Class 4 tiebreaker — no behavior change there
|
||||
|
||||
### Caller updates
|
||||
|
||||
Every caller of `sortWorktreesSmart` (and the smart branch of `buildWorktreeComparator`) MUST thread `agentStatusByPaneKey`. With the terminal-title heuristic dropped from sort (Open Question #3), a caller that omits agent status collapses every worktree to Class 4 — no fallback signal carries the ranking.
|
||||
|
||||
Callers today (verify before each edit):
|
||||
|
||||
- `src/renderer/src/components/sidebar/visible-worktrees.ts:179` — already threads `state.agentStatusByPaneKey` (no change needed).
|
||||
- `src/renderer/src/components/sidebar/visible-worktrees.ts:188` — already threads `state.agentStatusByPaneKey` (no change needed).
|
||||
- `src/renderer/src/components/sidebar/WorktreeList.tsx:652` — uses `buildWorktreeComparator`; verify it threads agent status (it does today via the parent state read).
|
||||
- `src/renderer/src/components/WorktreeJumpPalette.tsx:227` and `:239` — currently `sortWorktreesSmart(visibleWorktrees, tabsByWorktree, repoMap, prCache)` (4 args). **Must change** to pass `agentStatusByPaneKey` from the store.
|
||||
|
||||
Make `agentStatusByPaneKey` **non-optional** in the new `sortWorktreesSmart` signature. Why: a forgotten caller fails type-check rather than silently regressing palette ordering to "all Class 4". Same for the `agentStatusByPaneKey` parameter on `buildWorktreeComparator`'s smart branch.
|
||||
|
||||
### Implementation plan (step-by-step)
|
||||
|
||||
This section is the implementation handoff. Follow in order; each step lands a coherent, testable change.
|
||||
|
||||
**Step 1 — Add the class + attention helpers.**
|
||||
- New file: `src/renderer/src/components/sidebar/smart-attention.ts`.
|
||||
- Export `SmartClass = 1 | 2 | 3 | 4`, `WorktreeAttention = { cls: SmartClass; attentionTimestamp: number }`, the constant `IDLE: WorktreeAttention = { cls: 4, attentionTimestamp: 0 }`.
|
||||
- Implement `resolveAttention(panes, now)` and `mostRecentAttentionInHistory(history)` exactly as in "Per-worktree resolution" above.
|
||||
- Unit tests for the helper directly (faster to iterate than going through the full sort): one entry per class, multi-pane min-class/max-timestamp, interrupted-done skip, history-only-interrupted fallback, stale-entry skip.
|
||||
|
||||
**Step 2 — Build the per-worktree attention map.**
|
||||
- Add `buildAttentionByWorktree(worktrees, tabsByWorktree, agentStatusByPaneKey, now)` to `smart-attention.ts`.
|
||||
- Reuse the existing `buildExplicitEntriesByTabId` index from `smart-sort.ts` (move it into `smart-attention.ts` if cleaner — it's only used by the smart path).
|
||||
- Walk each worktree's tabs, collect their `AgentStatusEntry`s, call `resolveAttention`, store result in a `Map<worktreeId, WorktreeAttention>`.
|
||||
|
||||
**Step 3 — Rewrite the comparator.**
|
||||
- In `smart-sort.ts`, replace the smart branch of `buildWorktreeComparator` with the class-then-recency comparator from "The comparator" section.
|
||||
- Replace `precomputedScores` parameter with `attentionByWorktree: Map<string, WorktreeAttention>`.
|
||||
- Make `agentStatusByPaneKey` non-optional on this parameter list (TypeScript will surface every caller).
|
||||
- Delete `SmartSortOverride`, `smartSortOverrides`, `getSmartSortCandidate`, `precomputedScores`, `computeSmartScore`, `computeSmartScoreFromSignals`, `hasRecentPRSignal`. Keep `effectiveRecentActivity` and `CREATE_GRACE_MS` (used by `recent` and Class 4 tiebreaker).
|
||||
|
||||
**Step 4 — Rewrite `sortWorktreesSmart`.**
|
||||
- Build `attentionByWorktree` once via `buildAttentionByWorktree`.
|
||||
- Pass it into the comparator.
|
||||
- Keep the existing cold-start branch (`!hasAnyLivePty`) using persisted `sortOrder` as today — see Open Question #5.
|
||||
|
||||
**Step 5 — Update callers.**
|
||||
- Update `WorktreeJumpPalette.tsx:227` and `:239` to pass `state.agentStatusByPaneKey`.
|
||||
- Run `pnpm typecheck` to confirm no other callers slipped through.
|
||||
|
||||
**Step 6 — Rewrite tests.**
|
||||
- Replace numerical-score assertions in `smart-sort.test.ts` with the ordering invariants listed in "Tests to rewrite" below.
|
||||
- Add a new test file `smart-attention.test.ts` for the `resolveAttention` helper.
|
||||
- Add the palette regression test (covered in "Tests to rewrite").
|
||||
|
||||
**Step 7 — Verify and ship.**
|
||||
- `pnpm typecheck && pnpm test smart-sort smart-attention`.
|
||||
- Manual smoke: open Orca with several worktrees + agents in different states, switch to Smart sort, verify ordering matches the class table.
|
||||
- Confirm no new console errors when `agentStatusByPaneKey` is empty (e.g., very early in app startup).
|
||||
|
||||
**What this design depends on, in order:**
|
||||
1. Default-on agent status — **already merged on `main`** (PR #1538).
|
||||
2. Persistence of `lastStatusByPaneKey` across restart — **separate change on the sibling branch** `agent-status-preserve-restart`. Smart-sort code can ship before this; the "Smart sees pre-quit state after restart" guarantee comes online when persistence merges. Verify the sibling branch's `AgentStatusIpcPayload` and `setAgentStatus`'s 4th `timing` arg are already present before relying on Edge case 8's claim about restored timestamps.
|
||||
|
||||
### Tests to rewrite
|
||||
|
||||
`smart-sort.test.ts` asserts numerical scores in a few places. New tests assert ordering invariants:
|
||||
|
||||
- A worktree whose agent is `blocked` ranks above one whose agent is `done`, regardless of which `stateStartedAt` is newer (class invariant).
|
||||
- A worktree whose agent is `done` ranks above one whose agent is `working` (class invariant).
|
||||
- Two `blocked` worktrees: the one whose `stateStartedAt` is more recent ranks first.
|
||||
- A worktree with `state='working'` and a prior `done` in `stateHistory` ranks above another `working` worktree with no history.
|
||||
- A `working` worktree whose history contains only interrupted-`done` entries falls back to the current `stateStartedAt` (the helper returns null and `??` activates).
|
||||
- `interrupted` `done` worktrees rank in Class 4, not Class 2.
|
||||
- Stale entries (`updatedAt > AGENT_STATUS_STALE_AFTER_MS` ago) are ignored — worktree falls to Class 4.
|
||||
- Class 4 ties break on `effectiveRecentActivity`, then `displayName`.
|
||||
- `working` transitions never promote a worktree above a `blocked` one.
|
||||
- **Palette caller regression**: `WorktreeJumpPalette` ranks a `blocked` worktree above a `working` one when both flow through `sortWorktreesSmart` with `agentStatusByPaneKey` threaded — pins that the palette path uses class, not the dropped heuristic.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Should we persist `attentionTimestamp` per worktree?** Recommendation: **no, for v1.** The hook-server cache persistence (sibling branch) covers the practical cold-start case — restart restores per-pane current state. A second persistence layer for worktree-level attention is redundant and adds a migration. Revisit only if users report cross-restart staleness in the `stateHistory`-only edge case (Edge case 8).
|
||||
2. **Should viewing the worktree clear/demote the class?** Recommendation: **no for v1.** Keeping Class membership tied to objective agent state (not user view) means the dot, badge, and rank position tell a consistent story. The unread badge already records "you haven't seen it yet" and is the right place for view-acknowledged semantics. If users complain that the top of the list is stuck on something they already handled, the simplest follow-up is "demote on activation if and only if the unread badge clears" — small, scoped, additive.
|
||||
3. **What about the heuristic permission-prompt detector for no-hook agents?** Recommendation: **drop it from sort.** The heuristic is fragile (terminal-title-string matching) and the new design is built on hook state. Agents without hooks (aider, custom shell scripts) land in Class 4 — same rank position as today's `recent` would give them. The detector continues to drive the per-card status dot for visibility, but no longer feeds into ranking. Trading a small visible-rank regression for non-hook agents in exchange for a much simpler, harder-to-fool comparator.
|
||||
4. **What about a user actively typing in a no-agent worktree?** With Class 4 idle worktrees ordered by `effectiveRecentActivity`, an active no-agent worktree ranks at the top of Class 4 (above truly-stale ones) but still below every Class 1/2/3 worktree. For users whose primary workflow is "type in a worktree without an agent," Smart will feel agent-centric. **This is by design** — that's what `recent` is for. We should consider renaming the `lastActivityAt` tiebreaker logic if user feedback suggests confusion, but no change in v1.
|
||||
5. **Migration.** Existing users with `sortBy: 'smart'` keep that selection — they get the new behavior automatically. The persisted `sortOrder` snapshot (used in cold-start before any PTY is alive) becomes meaningless under the new comparator; we discard it on first warm sort and re-snapshot. First cold start after this lands uses the pre-existing `sortOrder` snapshot until the persisted hook-server cache hydrates and the first warm sort runs (~1 frame after the bootstrap snapshot arrives), then the snapshot is overwritten. Briefly stale-looking ordering on first launch after upgrade is acceptable; no explicit invalidation needed. No user-visible migration needed.
|
||||
@@ -153,6 +153,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId)
|
||||
const prCache = useAppStore((s) => s.prCache)
|
||||
const issueCache = useAppStore((s) => s.issueCache)
|
||||
const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey)
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const activeTabType = useAppStore((s) => s.activeTabType)
|
||||
const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId)
|
||||
@@ -224,9 +225,16 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
const sortedWorktrees = useMemo(
|
||||
() =>
|
||||
hasQuery
|
||||
? sortWorktreesSmart(visibleWorktrees, tabsByWorktree, repoMap, prCache)
|
||||
? sortWorktreesSmart(visibleWorktrees, tabsByWorktree, repoMap, agentStatusByPaneKey)
|
||||
: switchableWorktreesForRows,
|
||||
[hasQuery, visibleWorktrees, switchableWorktreesForRows, tabsByWorktree, repoMap, prCache]
|
||||
[
|
||||
hasQuery,
|
||||
visibleWorktrees,
|
||||
switchableWorktreesForRows,
|
||||
tabsByWorktree,
|
||||
repoMap,
|
||||
agentStatusByPaneKey
|
||||
]
|
||||
)
|
||||
|
||||
const browserSortedWorktrees = useMemo(() => {
|
||||
@@ -236,8 +244,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
// tab on the default-branch worktree before toggling hide-on should still
|
||||
// be able to Cmd+J back to it — the setting hides the *workspace row*,
|
||||
// not the browser tabs that live inside it.
|
||||
return sortWorktreesSmart(allWorktrees, tabsByWorktree, repoMap, prCache)
|
||||
}, [allWorktrees, tabsByWorktree, repoMap, prCache])
|
||||
return sortWorktreesSmart(allWorktrees, tabsByWorktree, repoMap, agentStatusByPaneKey)
|
||||
}, [allWorktrees, tabsByWorktree, repoMap, agentStatusByPaneKey])
|
||||
|
||||
// Why: browser rows need worktree lookups for repo badge colors, and browser
|
||||
// search intentionally includes archived worktrees. This map must cover all
|
||||
|
||||
@@ -15,11 +15,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Worktree, Repo } from '../../../../shared/types'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import {
|
||||
buildExplicitEntriesByTabId,
|
||||
buildWorktreeComparator,
|
||||
computeSmartScore
|
||||
} from './smart-sort'
|
||||
import { buildWorktreeComparator } from './smart-sort'
|
||||
import { buildAttentionByWorktree } from './smart-attention'
|
||||
import {
|
||||
type GroupHeaderRow,
|
||||
type Row,
|
||||
@@ -618,49 +615,19 @@ const WorktreeList = React.memo(function WorktreeList() {
|
||||
const currentTabs = state.tabsByWorktree
|
||||
const now = Date.now()
|
||||
// Why precompute: this is the hot sidebar sort. Array.sort invokes the
|
||||
// comparator O(N log N) times, and the smart-score computation would
|
||||
// otherwise scan `agentStatusByPaneKey` (O(E)) or do per-worktree O(T)
|
||||
// index lookups on every call. Two layered optimizations:
|
||||
// 1. Build the tabId → explicit-entries index ONCE (O(E)) so the
|
||||
// per-worktree scoring does cheap lookups instead of rescanning.
|
||||
// 2. Precompute scores once per worktree (decorate-sort-undecorate) so
|
||||
// the comparator does O(1) map lookups instead of re-scoring per
|
||||
// comparison.
|
||||
// Combined: O(E) index + O(N×T) scoring + O(N log N) sort, instead of
|
||||
// O(N × E × T) per sortEpoch bump. Only smart mode uses the score map;
|
||||
// other modes ignore it.
|
||||
const explicitByTabId =
|
||||
sortBy === 'smart' ? buildExplicitEntriesByTabId(state.agentStatusByPaneKey) : undefined
|
||||
const precomputedScores =
|
||||
// comparator O(N log N) times. Build the per-worktree attention map ONCE
|
||||
// (O(E + N×T×H) where H = stateHistory length, bounded at 20) so the
|
||||
// comparator does O(1) map lookups instead of re-resolving per comparison.
|
||||
const attentionByWorktree =
|
||||
sortBy === 'smart'
|
||||
? new Map<string, number>(
|
||||
nonArchivedWorktrees.map((w) => [
|
||||
w.id,
|
||||
computeSmartScore(
|
||||
w,
|
||||
currentTabs,
|
||||
repoMap,
|
||||
state.prCache,
|
||||
now,
|
||||
state.agentStatusByPaneKey,
|
||||
explicitByTabId
|
||||
)
|
||||
])
|
||||
? buildAttentionByWorktree(
|
||||
nonArchivedWorktrees,
|
||||
currentTabs,
|
||||
state.agentStatusByPaneKey,
|
||||
now
|
||||
)
|
||||
: undefined
|
||||
nonArchivedWorktrees.sort(
|
||||
buildWorktreeComparator(
|
||||
sortBy,
|
||||
currentTabs,
|
||||
repoMap,
|
||||
state.prCache,
|
||||
now,
|
||||
null,
|
||||
state.agentStatusByPaneKey,
|
||||
precomputedScores,
|
||||
explicitByTabId
|
||||
)
|
||||
)
|
||||
: null
|
||||
nonArchivedWorktrees.sort(buildWorktreeComparator(sortBy, repoMap, now, attentionByWorktree))
|
||||
return nonArchivedWorktrees.map((w) => w.id)
|
||||
// debouncedSortEpoch is an intentional trigger: it's not read inside the
|
||||
// memo, but its change signals that the sort order should be recomputed.
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStateHistoryEntry,
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import {
|
||||
IDLE,
|
||||
buildAttentionByWorktree,
|
||||
mostRecentAttentionInHistory,
|
||||
resolveAttention
|
||||
} from './smart-attention'
|
||||
import type { TerminalTab, Worktree } from '../../../../shared/types'
|
||||
|
||||
const NOW = new Date('2026-03-27T12:00:00.000Z').getTime()
|
||||
|
||||
function makeEntry(overrides: Partial<AgentStatusEntry> & { paneKey: string }): AgentStatusEntry {
|
||||
return {
|
||||
state: overrides.state ?? 'working',
|
||||
prompt: overrides.prompt ?? '',
|
||||
updatedAt: overrides.updatedAt ?? NOW - 30_000,
|
||||
stateStartedAt: overrides.stateStartedAt ?? overrides.updatedAt ?? NOW - 30_000,
|
||||
agentType: overrides.agentType ?? 'codex',
|
||||
paneKey: overrides.paneKey,
|
||||
terminalTitle: overrides.terminalTitle,
|
||||
stateHistory: overrides.stateHistory ?? [],
|
||||
interrupted: overrides.interrupted
|
||||
}
|
||||
}
|
||||
|
||||
function makeHistory(
|
||||
state: AgentStateHistoryEntry['state'],
|
||||
startedAt: number,
|
||||
interrupted = false
|
||||
): AgentStateHistoryEntry {
|
||||
return { state, prompt: '', startedAt, interrupted: interrupted || undefined }
|
||||
}
|
||||
|
||||
describe('mostRecentAttentionInHistory', () => {
|
||||
it('returns null on an empty history', () => {
|
||||
expect(mostRecentAttentionInHistory([])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the latest done/blocked/waiting startedAt', () => {
|
||||
const result = mostRecentAttentionInHistory([
|
||||
makeHistory('working', NOW - 5_000),
|
||||
makeHistory('done', NOW - 4_000),
|
||||
makeHistory('working', NOW - 3_000),
|
||||
makeHistory('blocked', NOW - 2_000),
|
||||
makeHistory('working', NOW - 1_000)
|
||||
])
|
||||
expect(result).toBe(NOW - 2_000)
|
||||
})
|
||||
|
||||
it('skips interrupted done rows', () => {
|
||||
expect(
|
||||
mostRecentAttentionInHistory([
|
||||
makeHistory('done', NOW - 4_000),
|
||||
makeHistory('done', NOW - 1_000, true)
|
||||
])
|
||||
).toBe(NOW - 4_000)
|
||||
})
|
||||
|
||||
it('returns null when only interrupted dones exist', () => {
|
||||
expect(mostRecentAttentionInHistory([makeHistory('done', NOW - 1_000, true)])).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores working rows entirely', () => {
|
||||
expect(
|
||||
mostRecentAttentionInHistory([
|
||||
makeHistory('working', NOW - 1_000),
|
||||
makeHistory('working', NOW - 2_000)
|
||||
])
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveAttention', () => {
|
||||
it('returns idle when there are no panes', () => {
|
||||
expect(resolveAttention([], NOW)).toEqual(IDLE)
|
||||
})
|
||||
|
||||
it('classifies a blocked pane as Class 1 with stateStartedAt', () => {
|
||||
const entry = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 60_000,
|
||||
updatedAt: NOW - 30_000
|
||||
})
|
||||
expect(resolveAttention([entry], NOW)).toEqual({ cls: 1, attentionTimestamp: NOW - 60_000 })
|
||||
})
|
||||
|
||||
it('classifies a waiting pane as Class 1', () => {
|
||||
const entry = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'waiting',
|
||||
stateStartedAt: NOW - 60_000,
|
||||
updatedAt: NOW - 30_000
|
||||
})
|
||||
expect(resolveAttention([entry], NOW).cls).toBe(1)
|
||||
})
|
||||
|
||||
it('classifies a done pane as Class 2', () => {
|
||||
const entry = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'done',
|
||||
stateStartedAt: NOW - 90_000,
|
||||
updatedAt: NOW - 30_000
|
||||
})
|
||||
expect(resolveAttention([entry], NOW)).toEqual({ cls: 2, attentionTimestamp: NOW - 90_000 })
|
||||
})
|
||||
|
||||
it('treats interrupted done as idle', () => {
|
||||
const entry = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'done',
|
||||
interrupted: true,
|
||||
stateStartedAt: NOW - 90_000,
|
||||
updatedAt: NOW - 30_000
|
||||
})
|
||||
expect(resolveAttention([entry], NOW)).toEqual(IDLE)
|
||||
})
|
||||
|
||||
it('classifies a working pane with prior done as Class 3 with the prior timestamp', () => {
|
||||
const entry = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 10_000,
|
||||
updatedAt: NOW - 1_000,
|
||||
stateHistory: [makeHistory('done', NOW - 5 * 60_000)]
|
||||
})
|
||||
expect(resolveAttention([entry], NOW)).toEqual({
|
||||
cls: 3,
|
||||
attentionTimestamp: NOW - 5 * 60_000
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to current stateStartedAt when working has no prior attention history', () => {
|
||||
const entry = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 10_000,
|
||||
updatedAt: NOW - 1_000,
|
||||
stateHistory: []
|
||||
})
|
||||
expect(resolveAttention([entry], NOW)).toEqual({
|
||||
cls: 3,
|
||||
attentionTimestamp: NOW - 10_000
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back when history contains only interrupted done rows', () => {
|
||||
const entry = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 10_000,
|
||||
updatedAt: NOW - 1_000,
|
||||
stateHistory: [makeHistory('done', NOW - 60_000, true)]
|
||||
})
|
||||
expect(resolveAttention([entry], NOW)).toEqual({
|
||||
cls: 3,
|
||||
attentionTimestamp: NOW - 10_000
|
||||
})
|
||||
})
|
||||
|
||||
it('skips stale entries (updatedAt older than the freshness window)', () => {
|
||||
const entry = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 60_000,
|
||||
updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 60_000
|
||||
})
|
||||
expect(resolveAttention([entry], NOW)).toEqual(IDLE)
|
||||
})
|
||||
|
||||
it('takes the most attention-demanding class across multiple panes', () => {
|
||||
const blocked = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 30_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
const done = makeEntry({
|
||||
paneKey: 't:2',
|
||||
state: 'done',
|
||||
stateStartedAt: NOW - 5_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
const working = makeEntry({
|
||||
paneKey: 't:3',
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 1_000,
|
||||
updatedAt: NOW - 100
|
||||
})
|
||||
expect(resolveAttention([done, working, blocked], NOW).cls).toBe(1)
|
||||
})
|
||||
|
||||
it('within the resolved class, takes the freshest attention timestamp across panes', () => {
|
||||
const olderBlocked = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
const newerBlocked = makeEntry({
|
||||
paneKey: 't:2',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 5_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
expect(resolveAttention([olderBlocked, newerBlocked], NOW)).toEqual({
|
||||
cls: 1,
|
||||
attentionTimestamp: NOW - 5_000
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAttentionByWorktree', () => {
|
||||
function makeWorktree(id: string): Worktree {
|
||||
return {
|
||||
id,
|
||||
repoId: 'repo-1',
|
||||
path: `/tmp/${id}`,
|
||||
branch: `refs/heads/${id}`,
|
||||
head: 'abc',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
comment: '',
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
displayName: id,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
function makeTab(id: string, worktreeId: string): TerminalTab {
|
||||
return {
|
||||
id,
|
||||
ptyId: 'pty',
|
||||
worktreeId,
|
||||
title: 'bash',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
it('returns IDLE for worktrees with no tabs', () => {
|
||||
const w = makeWorktree('wt-1')
|
||||
const map = buildAttentionByWorktree([w], {}, {}, NOW)
|
||||
expect(map.get(w.id)).toEqual(IDLE)
|
||||
})
|
||||
|
||||
it('aggregates entries across multiple panes on the same tab', () => {
|
||||
const w = makeWorktree('wt-1')
|
||||
const tab = makeTab('tab-1', w.id)
|
||||
const entries: Record<string, AgentStatusEntry> = {
|
||||
'tab-1:1': makeEntry({
|
||||
paneKey: 'tab-1:1',
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 10_000,
|
||||
updatedAt: NOW - 1_000
|
||||
}),
|
||||
'tab-1:2': makeEntry({
|
||||
paneKey: 'tab-1:2',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 5_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
}
|
||||
const map = buildAttentionByWorktree([w], { [w.id]: [tab] }, entries, NOW)
|
||||
expect(map.get(w.id)).toEqual({ cls: 1, attentionTimestamp: NOW - 5_000 })
|
||||
})
|
||||
|
||||
it('skips malformed paneKeys (no colon)', () => {
|
||||
const w = makeWorktree('wt-1')
|
||||
const tab = makeTab('tab-1', w.id)
|
||||
const map = buildAttentionByWorktree(
|
||||
[w],
|
||||
{ [w.id]: [tab] },
|
||||
{
|
||||
malformed: makeEntry({
|
||||
paneKey: 'malformed',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 1_000,
|
||||
updatedAt: NOW - 100
|
||||
})
|
||||
},
|
||||
NOW
|
||||
)
|
||||
expect(map.get(w.id)).toEqual(IDLE)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
|
||||
import type { TerminalTab, Worktree } from '../../../../shared/types'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStateHistoryEntry,
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
|
||||
/**
|
||||
* Ordinal class for the "Smart" sort. Lower number = more attention-demanding.
|
||||
* 1 — Needs you (`blocked` / `waiting`)
|
||||
* 2 — Done (`done`, not interrupted)
|
||||
* 3 — Working (`working`)
|
||||
* 4 — Idle (no live entry, stale entry, or interrupted `done`)
|
||||
*
|
||||
* Class is the primary sort key; within a class the comparator falls back to
|
||||
* the resolved attention timestamp. See docs/smart-worktree-order-redesign.md.
|
||||
*/
|
||||
export type SmartClass = 1 | 2 | 3 | 4
|
||||
|
||||
/**
|
||||
* Per-worktree resolution computed once before sorting.
|
||||
*
|
||||
* `attentionTimestamp` semantics depend on the class:
|
||||
* - Class 1 / 2: `stateStartedAt` of the current entry (when the agent
|
||||
* entered the attention state).
|
||||
* - Class 3: `stateStartedAt` of the most recent prior `done`/`blocked`/
|
||||
* `waiting` entry in `stateHistory[]`, falling back to the current
|
||||
* `working` `stateStartedAt` when no prior attention event exists.
|
||||
* - Class 4: `0` — the comparator drops to `effectiveRecentActivity` for
|
||||
* within-class ordering on idle worktrees.
|
||||
*/
|
||||
export type WorktreeAttention = {
|
||||
cls: SmartClass
|
||||
attentionTimestamp: number
|
||||
}
|
||||
|
||||
export const IDLE: WorktreeAttention = { cls: 4, attentionTimestamp: 0 }
|
||||
|
||||
/**
|
||||
* Walk a pane's state-history rows and return the timestamp of the most
|
||||
* recent `done`/`blocked`/`waiting` entry, ignoring `done` rows that were
|
||||
* interrupted (the user pressed Ctrl+C — that turn no longer demands
|
||||
* attention). Returns `null` when no qualifying row exists.
|
||||
*/
|
||||
export function mostRecentAttentionInHistory(history: AgentStateHistoryEntry[]): number | null {
|
||||
let max = 0
|
||||
for (const h of history) {
|
||||
// Why: setAgentStatus preserves `interrupted` on history rows when an
|
||||
// interrupted `done` transitions out, so we can filter on history the
|
||||
// same way the current entry does.
|
||||
if (h.state === 'done' && h.interrupted) {
|
||||
continue
|
||||
}
|
||||
if (h.state === 'done' || h.state === 'blocked' || h.state === 'waiting') {
|
||||
if (h.startedAt > max) {
|
||||
max = h.startedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
return max > 0 ? max : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a worktree's class + attention timestamp from its panes' agent
|
||||
* status entries. Stale entries (older than `AGENT_STATUS_STALE_AFTER_MS`)
|
||||
* are skipped — the worktree falls to Class 4 if no fresh entry exists.
|
||||
*
|
||||
* Across multiple panes:
|
||||
* - `cls` is the **min** (most attention-demanding pane wins).
|
||||
* - `attentionTimestamp` is the **max** within the resolved class.
|
||||
*/
|
||||
export function resolveAttention(panes: AgentStatusEntry[], now: number): WorktreeAttention {
|
||||
let bestCls: SmartClass = 4
|
||||
let bestTs = 0
|
||||
|
||||
for (const entry of panes) {
|
||||
if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
|
||||
continue
|
||||
}
|
||||
|
||||
let cls: SmartClass
|
||||
let ts: number
|
||||
if (entry.state === 'blocked' || entry.state === 'waiting') {
|
||||
cls = 1
|
||||
ts = entry.stateStartedAt
|
||||
} else if (entry.state === 'done') {
|
||||
// Why: an interrupted `done` (user pressed Ctrl+C) is the user signalling
|
||||
// "I'm done with this turn". Treat as idle, not as Class 2 attention.
|
||||
if (entry.interrupted) {
|
||||
continue
|
||||
}
|
||||
cls = 2
|
||||
ts = entry.stateStartedAt
|
||||
} else {
|
||||
// working
|
||||
cls = 3
|
||||
// Why: within Class 3, sort by the most recent prior attention event so
|
||||
// a worktree that just transitioned done→working stays above one that's
|
||||
// been working for an hour. Falls back to the current stateStartedAt
|
||||
// when stateHistory is empty (e.g. fresh after restart).
|
||||
const prior = mostRecentAttentionInHistory(entry.stateHistory)
|
||||
ts = prior ?? entry.stateStartedAt
|
||||
}
|
||||
|
||||
// Why min on class: smaller class number = higher priority. Any pane in a
|
||||
// more attention-demanding class promotes the whole worktree. Within the
|
||||
// same class, take the max timestamp so the freshest attention event wins.
|
||||
if (cls < bestCls || (cls === bestCls && ts > bestTs)) {
|
||||
bestCls = cls
|
||||
bestTs = ts
|
||||
}
|
||||
}
|
||||
|
||||
return { cls: bestCls, attentionTimestamp: bestTs }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `tabId → entries[]` index over `agentStatusByPaneKey`. Entries are
|
||||
* keyed by the `tabId` prefix of their paneKey (paneKey format:
|
||||
* `${tabId}:${paneId}`). Doing this once per sort lets each worktree's
|
||||
* resolution pay O(T) lookups instead of scanning the full map.
|
||||
*/
|
||||
export function buildExplicitEntriesByTabId(
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry> | undefined
|
||||
): Map<string, AgentStatusEntry[]> {
|
||||
const byTab = new Map<string, AgentStatusEntry[]>()
|
||||
if (!agentStatusByPaneKey) {
|
||||
return byTab
|
||||
}
|
||||
for (const entry of Object.values(agentStatusByPaneKey)) {
|
||||
const colon = entry.paneKey.indexOf(':')
|
||||
// Why: paneKey must be `${tabId}:${paneId}`. Skip malformed entries (no
|
||||
// colon or leading colon) rather than bucketing them under an empty tabId.
|
||||
if (colon <= 0) {
|
||||
continue
|
||||
}
|
||||
const tabId = entry.paneKey.slice(0, colon)
|
||||
const bucket = byTab.get(tabId)
|
||||
if (bucket) {
|
||||
bucket.push(entry)
|
||||
} else {
|
||||
byTab.set(tabId, [entry])
|
||||
}
|
||||
}
|
||||
return byTab
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-worktree attention map consumed by the smart comparator.
|
||||
*
|
||||
* Cost: O(E + N × T × H) where E = total entries, N = worktrees, T = tabs per
|
||||
* worktree, H = history length (bounded at AGENT_STATE_HISTORY_MAX = 20).
|
||||
*/
|
||||
export function buildAttentionByWorktree(
|
||||
worktrees: Worktree[],
|
||||
tabsByWorktree: Record<string, TerminalTab[]> | null,
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry> | undefined,
|
||||
now: number
|
||||
): Map<string, WorktreeAttention> {
|
||||
const byTab = buildExplicitEntriesByTabId(agentStatusByPaneKey)
|
||||
const result = new Map<string, WorktreeAttention>()
|
||||
|
||||
for (const worktree of worktrees) {
|
||||
const tabs = tabsByWorktree?.[worktree.id]
|
||||
if (!tabs || tabs.length === 0) {
|
||||
result.set(worktree.id, IDLE)
|
||||
continue
|
||||
}
|
||||
const panes: AgentStatusEntry[] = []
|
||||
for (const tab of tabs) {
|
||||
const bucket = byTab.get(tab.id)
|
||||
if (bucket) {
|
||||
for (const entry of bucket) {
|
||||
panes.push(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
result.set(worktree.id, resolveAttention(panes, now))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
/* eslint-disable max-lines */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Repo, TerminalTab, Worktree } from '../../../../shared/types'
|
||||
import {
|
||||
buildWorktreeComparator,
|
||||
computeSmartScore,
|
||||
CREATE_GRACE_MS,
|
||||
effectiveRecentActivity,
|
||||
type SmartSortOverride
|
||||
sortWorktreesSmart
|
||||
} from './smart-sort'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { buildAttentionByWorktree } from './smart-attention'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStateHistoryEntry,
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
|
||||
const NOW = new Date('2026-03-27T12:00:00.000Z').getTime()
|
||||
|
||||
@@ -61,9 +65,7 @@ function makeTab(overrides: Partial<TerminalTab> = {}): TerminalTab {
|
||||
}
|
||||
}
|
||||
|
||||
function makeAgentStatusEntry(
|
||||
overrides: Partial<AgentStatusEntry> & { paneKey: string }
|
||||
): AgentStatusEntry {
|
||||
function makeEntry(overrides: Partial<AgentStatusEntry> & { paneKey: string }): AgentStatusEntry {
|
||||
return {
|
||||
state: overrides.state ?? 'working',
|
||||
prompt: overrides.prompt ?? '',
|
||||
@@ -72,372 +74,426 @@ function makeAgentStatusEntry(
|
||||
agentType: overrides.agentType ?? 'codex',
|
||||
paneKey: overrides.paneKey,
|
||||
terminalTitle: overrides.terminalTitle,
|
||||
stateHistory: overrides.stateHistory ?? []
|
||||
stateHistory: overrides.stateHistory ?? [],
|
||||
interrupted: overrides.interrupted
|
||||
}
|
||||
}
|
||||
|
||||
describe('computeSmartScore', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
function makeHistory(
|
||||
state: AgentStateHistoryEntry['state'],
|
||||
startedAt: number,
|
||||
interrupted = false
|
||||
): AgentStateHistoryEntry {
|
||||
return { state, prompt: '', startedAt, interrupted: interrupted || undefined }
|
||||
}
|
||||
|
||||
it('prioritizes recent activity over a merely linked worktree', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(NOW)
|
||||
/**
|
||||
* Sort helper: builds the attention map and runs the smart comparator. Mirrors
|
||||
* what callers do in production (visible-worktrees, WorktreeList).
|
||||
*/
|
||||
function sortSmart(
|
||||
worktrees: Worktree[],
|
||||
tabsByWorktree: Record<string, TerminalTab[]>,
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>
|
||||
): Worktree[] {
|
||||
const attention = buildAttentionByWorktree(worktrees, tabsByWorktree, agentStatusByPaneKey, NOW)
|
||||
return [...worktrees].sort(buildWorktreeComparator('smart', repoMap, NOW, attention))
|
||||
}
|
||||
|
||||
const active = makeWorktree({
|
||||
id: 'active',
|
||||
displayName: 'Active',
|
||||
lastActivityAt: NOW - 10 * 60 * 1000
|
||||
})
|
||||
const linked = makeWorktree({
|
||||
id: 'linked',
|
||||
displayName: 'Linked',
|
||||
linkedIssue: 42
|
||||
})
|
||||
|
||||
const prCache = {
|
||||
'/tmp/repo-1::linked': {
|
||||
data: { number: 17 },
|
||||
fetchedAt: NOW
|
||||
}
|
||||
describe('smart sort — class invariants', () => {
|
||||
it('ranks blocked above done regardless of which stateStartedAt is newer', () => {
|
||||
const blocked = makeWorktree({ id: 'blocked', displayName: 'Blocked' })
|
||||
const done = makeWorktree({ id: 'done', displayName: 'Done' })
|
||||
const tabs = {
|
||||
[blocked.id]: [makeTab({ id: 'tab-blocked', worktreeId: blocked.id })],
|
||||
[done.id]: [makeTab({ id: 'tab-done', worktreeId: done.id })]
|
||||
}
|
||||
|
||||
expect(computeSmartScore(active, null, repoMap, null)).toBeGreaterThan(
|
||||
computeSmartScore(linked, null, repoMap, prCache)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps recent activity relevant beyond a one-hour window', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(NOW)
|
||||
|
||||
const recent = makeWorktree({
|
||||
id: 'recent',
|
||||
lastActivityAt: NOW - 2 * 60 * 60 * 1000
|
||||
})
|
||||
const stale = makeWorktree({
|
||||
id: 'stale',
|
||||
lastActivityAt: NOW - 30 * 60 * 60 * 1000
|
||||
})
|
||||
|
||||
expect(computeSmartScore(recent, null, repoMap, null)).toBeGreaterThan(
|
||||
computeSmartScore(stale, null, repoMap, null)
|
||||
)
|
||||
})
|
||||
|
||||
it('rewards live terminals even without detected agent status', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(NOW)
|
||||
|
||||
const withLiveTerminal = makeWorktree({ id: 'live' })
|
||||
const withoutLiveTerminal = makeWorktree({ id: 'offline' })
|
||||
const tabsByWorktree = {
|
||||
[withLiveTerminal.id]: [makeTab({ worktreeId: withLiveTerminal.id, title: 'bash' })]
|
||||
}
|
||||
|
||||
expect(computeSmartScore(withLiveTerminal, tabsByWorktree, repoMap, null)).toBeGreaterThan(
|
||||
computeSmartScore(withoutLiveTerminal, tabsByWorktree, repoMap, null)
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the current branch PR cache instead of persisted linkedPR metadata', () => {
|
||||
const staleLinked = makeWorktree({
|
||||
id: 'stale-linked',
|
||||
branch: 'refs/heads/no-pr-anymore',
|
||||
linkedPR: 17
|
||||
})
|
||||
const livePR = makeWorktree({
|
||||
id: 'live-pr',
|
||||
branch: 'refs/heads/has-pr-now',
|
||||
linkedPR: null
|
||||
})
|
||||
const prCache = {
|
||||
'/tmp/repo-1::no-pr-anymore': {
|
||||
data: null,
|
||||
fetchedAt: NOW
|
||||
},
|
||||
'/tmp/repo-1::has-pr-now': {
|
||||
data: { number: 42 },
|
||||
fetchedAt: NOW
|
||||
}
|
||||
}
|
||||
|
||||
expect(computeSmartScore(livePR, null, repoMap, prCache)).toBeGreaterThan(
|
||||
computeSmartScore(staleLinked, null, repoMap, prCache)
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to linkedPR when the current branch cache entry is still cold', () => {
|
||||
const linked = makeWorktree({
|
||||
id: 'linked',
|
||||
branch: 'refs/heads/not-fetched-yet',
|
||||
linkedPR: 17
|
||||
})
|
||||
const plain = makeWorktree({
|
||||
id: 'plain',
|
||||
branch: 'refs/heads/plain',
|
||||
linkedPR: null
|
||||
})
|
||||
|
||||
expect(computeSmartScore(linked, null, repoMap, {})).toBeGreaterThan(
|
||||
computeSmartScore(plain, null, repoMap, {})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not let stale explicit status mask a live heuristic permission prompt', () => {
|
||||
const worktree = makeWorktree({ id: 'wt-1' })
|
||||
const tabsByWorktree = {
|
||||
[worktree.id]: [makeTab({ worktreeId: worktree.id, title: 'codex permission needed' })]
|
||||
}
|
||||
const score = computeSmartScore(worktree, tabsByWorktree, repoMap, null, NOW, {
|
||||
'tab-1:1': makeAgentStatusEntry({
|
||||
paneKey: 'tab-1:1',
|
||||
const entries = {
|
||||
'tab-blocked:1': makeEntry({
|
||||
paneKey: 'tab-blocked:1',
|
||||
state: 'blocked',
|
||||
// older than the done timestamp
|
||||
stateStartedAt: NOW - 5 * 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
}),
|
||||
'tab-done:1': makeEntry({
|
||||
paneKey: 'tab-done:1',
|
||||
state: 'done',
|
||||
updatedAt: NOW - 45 * 60_000
|
||||
// newer
|
||||
stateStartedAt: NOW - 10_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
})
|
||||
|
||||
expect(score).toBeGreaterThanOrEqual(35)
|
||||
}
|
||||
const sorted = sortSmart([done, blocked], tabs, entries)
|
||||
expect(sorted.map((w) => w.id)).toEqual(['blocked', 'done'])
|
||||
})
|
||||
|
||||
it('does not stack heuristic working on top of fresh explicit done for the same tab', () => {
|
||||
const worktree = makeWorktree({ id: 'wt-1' })
|
||||
const tabsByWorktree = {
|
||||
[worktree.id]: [makeTab({ worktreeId: worktree.id, title: 'codex working' })]
|
||||
it('ranks done above working', () => {
|
||||
const done = makeWorktree({ id: 'done', displayName: 'Done' })
|
||||
const working = makeWorktree({ id: 'working', displayName: 'Working' })
|
||||
const tabs = {
|
||||
[done.id]: [makeTab({ id: 'tab-done', worktreeId: done.id })],
|
||||
[working.id]: [makeTab({ id: 'tab-working', worktreeId: working.id })]
|
||||
}
|
||||
|
||||
expect(
|
||||
computeSmartScore(worktree, tabsByWorktree, repoMap, null, NOW, {
|
||||
'tab-1:1': makeAgentStatusEntry({
|
||||
paneKey: 'tab-1:1',
|
||||
state: 'done',
|
||||
updatedAt: NOW - 60_000
|
||||
})
|
||||
const entries = {
|
||||
'tab-done:1': makeEntry({
|
||||
paneKey: 'tab-done:1',
|
||||
state: 'done',
|
||||
stateStartedAt: NOW - 10 * 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
}),
|
||||
'tab-working:1': makeEntry({
|
||||
paneKey: 'tab-working:1',
|
||||
state: 'working',
|
||||
// newer than the done — must still lose because class wins
|
||||
stateStartedAt: NOW - 1_000,
|
||||
updatedAt: NOW - 500
|
||||
})
|
||||
).toBe(12)
|
||||
}
|
||||
const sorted = sortSmart([working, done], tabs, entries)
|
||||
expect(sorted.map((w) => w.id)).toEqual(['done', 'working'])
|
||||
})
|
||||
|
||||
it('ranks working above idle', () => {
|
||||
const working = makeWorktree({ id: 'working', displayName: 'Working' })
|
||||
const idle = makeWorktree({
|
||||
id: 'idle',
|
||||
displayName: 'Idle',
|
||||
// Make sure idle's effective recency is high enough that without the
|
||||
// class layer it would outrank the working worktree on a recency tie.
|
||||
lastActivityAt: NOW - 1_000
|
||||
})
|
||||
const tabs = {
|
||||
[working.id]: [makeTab({ id: 'tab-working', worktreeId: working.id })],
|
||||
[idle.id]: [makeTab({ id: 'tab-idle', worktreeId: idle.id })]
|
||||
}
|
||||
const entries = {
|
||||
'tab-working:1': makeEntry({
|
||||
paneKey: 'tab-working:1',
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
}
|
||||
const sorted = sortSmart([idle, working], tabs, entries)
|
||||
expect(sorted.map((w) => w.id)).toEqual(['working', 'idle'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildWorktreeComparator', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
describe('smart sort — within-class recency', () => {
|
||||
it('orders two blocked worktrees by stateStartedAt (newer first)', () => {
|
||||
const older = makeWorktree({ id: 'older', displayName: 'A-Older' })
|
||||
const newer = makeWorktree({ id: 'newer', displayName: 'B-Newer' })
|
||||
const tabs = {
|
||||
[older.id]: [makeTab({ id: 'tab-older', worktreeId: older.id })],
|
||||
[newer.id]: [makeTab({ id: 'tab-newer', worktreeId: newer.id })]
|
||||
}
|
||||
const entries = {
|
||||
'tab-older:1': makeEntry({
|
||||
paneKey: 'tab-older:1',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 5 * 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
}),
|
||||
'tab-newer:1': makeEntry({
|
||||
paneKey: 'tab-newer:1',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 30_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
}
|
||||
const sorted = sortSmart([older, newer], tabs, entries)
|
||||
expect(sorted.map((w) => w.id)).toEqual(['newer', 'older'])
|
||||
})
|
||||
|
||||
it('sorts smart mode by ongoing work signals before alphabetical order', () => {
|
||||
const active = makeWorktree({
|
||||
id: 'active',
|
||||
displayName: 'z-active',
|
||||
lastActivityAt: NOW - 10 * 60 * 1000
|
||||
it('ranks a working worktree with prior done above one with no history', () => {
|
||||
const withHistory = makeWorktree({ id: 'with-history', displayName: 'A-WithHistory' })
|
||||
const fresh = makeWorktree({ id: 'fresh', displayName: 'B-Fresh' })
|
||||
const tabs = {
|
||||
[withHistory.id]: [makeTab({ id: 'tab-with', worktreeId: withHistory.id })],
|
||||
[fresh.id]: [makeTab({ id: 'tab-fresh', worktreeId: fresh.id })]
|
||||
}
|
||||
const entries = {
|
||||
'tab-with:1': makeEntry({
|
||||
paneKey: 'tab-with:1',
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 60_000,
|
||||
updatedAt: NOW - 1_000,
|
||||
// Prior done from earlier in the session bumps within-class recency.
|
||||
stateHistory: [makeHistory('done', NOW - 5_000)]
|
||||
}),
|
||||
'tab-fresh:1': makeEntry({
|
||||
paneKey: 'tab-fresh:1',
|
||||
state: 'working',
|
||||
// Even newer current stateStartedAt — but with no history, falls back
|
||||
// to this timestamp (older than the prior done above).
|
||||
stateStartedAt: NOW - 2 * 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
}
|
||||
const sorted = sortSmart([fresh, withHistory], tabs, entries)
|
||||
expect(sorted.map((w) => w.id)).toEqual(['with-history', 'fresh'])
|
||||
})
|
||||
|
||||
it('falls back to current stateStartedAt when history is only interrupted dones', () => {
|
||||
const onlyInterrupted = makeWorktree({
|
||||
id: 'only-interrupted',
|
||||
displayName: 'A-OnlyInterrupted'
|
||||
})
|
||||
const recent = makeWorktree({
|
||||
id: 'recent',
|
||||
displayName: 'a-recent',
|
||||
lastActivityAt: NOW - 90 * 60 * 1000
|
||||
const fresh = makeWorktree({ id: 'fresh', displayName: 'B-Fresh' })
|
||||
const tabs = {
|
||||
[onlyInterrupted.id]: [makeTab({ id: 'tab-i', worktreeId: onlyInterrupted.id })],
|
||||
[fresh.id]: [makeTab({ id: 'tab-f', worktreeId: fresh.id })]
|
||||
}
|
||||
const entries = {
|
||||
'tab-i:1': makeEntry({
|
||||
paneKey: 'tab-i:1',
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 60_000,
|
||||
updatedAt: NOW - 1_000,
|
||||
stateHistory: [makeHistory('done', NOW - 5_000, true)]
|
||||
}),
|
||||
'tab-f:1': makeEntry({
|
||||
paneKey: 'tab-f:1',
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 30_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
}
|
||||
const sorted = sortSmart([onlyInterrupted, fresh], tabs, entries)
|
||||
// fresh has newer current stateStartedAt and onlyInterrupted's history is
|
||||
// skipped, so fresh wins on within-class recency.
|
||||
expect(sorted.map((w) => w.id)).toEqual(['fresh', 'only-interrupted'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('smart sort — interrupted and stale handling', () => {
|
||||
it('interrupted done worktrees fall to Class 4 (idle), not Class 2', () => {
|
||||
const interrupted = makeWorktree({
|
||||
id: 'interrupted',
|
||||
displayName: 'Interrupted',
|
||||
lastActivityAt: NOW - 60_000
|
||||
})
|
||||
const realDone = makeWorktree({ id: 'real-done', displayName: 'Real Done' })
|
||||
const tabs = {
|
||||
[interrupted.id]: [makeTab({ id: 'tab-i', worktreeId: interrupted.id })],
|
||||
[realDone.id]: [makeTab({ id: 'tab-d', worktreeId: realDone.id })]
|
||||
}
|
||||
const entries = {
|
||||
'tab-i:1': makeEntry({
|
||||
paneKey: 'tab-i:1',
|
||||
state: 'done',
|
||||
interrupted: true,
|
||||
stateStartedAt: NOW - 1_000,
|
||||
updatedAt: NOW - 500
|
||||
}),
|
||||
'tab-d:1': makeEntry({
|
||||
paneKey: 'tab-d:1',
|
||||
state: 'done',
|
||||
stateStartedAt: NOW - 5 * 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
}
|
||||
const sorted = sortSmart([interrupted, realDone], tabs, entries)
|
||||
expect(sorted.map((w) => w.id)).toEqual(['real-done', 'interrupted'])
|
||||
})
|
||||
|
||||
it('stale entries fall to Class 4', () => {
|
||||
const stale = makeWorktree({
|
||||
id: 'stale',
|
||||
displayName: 'm-stale',
|
||||
lastActivityAt: NOW - 3 * 24 * 60 * 60 * 1000
|
||||
})
|
||||
|
||||
const worktrees = [recent, stale, active]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('smart', null, repoMap, null, NOW))
|
||||
|
||||
expect(worktrees.map((worktree) => worktree.id)).toEqual(['active', 'recent', 'stale'])
|
||||
})
|
||||
|
||||
it('does not treat selection changes as recent activity', () => {
|
||||
const first = makeWorktree({
|
||||
id: 'first',
|
||||
displayName: 'First',
|
||||
sortOrder: NOW,
|
||||
displayName: 'Stale',
|
||||
lastActivityAt: NOW - 60_000
|
||||
})
|
||||
const second = makeWorktree({
|
||||
id: 'second',
|
||||
displayName: 'Second',
|
||||
sortOrder: NOW + 10_000,
|
||||
lastActivityAt: NOW - 120_000
|
||||
})
|
||||
|
||||
const worktrees = [second, first]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('smart', null, repoMap, null, NOW))
|
||||
|
||||
expect(worktrees.map((worktree) => worktree.id)).toEqual(['first', 'second'])
|
||||
const fresh = makeWorktree({ id: 'fresh', displayName: 'Fresh' })
|
||||
const tabs = {
|
||||
[stale.id]: [makeTab({ id: 'tab-s', worktreeId: stale.id })],
|
||||
[fresh.id]: [makeTab({ id: 'tab-f', worktreeId: fresh.id })]
|
||||
}
|
||||
const entries = {
|
||||
'tab-s:1': makeEntry({
|
||||
paneKey: 'tab-s:1',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 60_000,
|
||||
updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 60_000
|
||||
}),
|
||||
'tab-f:1': makeEntry({
|
||||
paneKey: 'tab-f:1',
|
||||
state: 'done',
|
||||
stateStartedAt: NOW - 5 * 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
}
|
||||
const sorted = sortSmart([stale, fresh], tabs, entries)
|
||||
// fresh is Class 2; stale falls to Class 4.
|
||||
expect(sorted.map((w) => w.id)).toEqual(['fresh', 'stale'])
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores stale sortOrder metadata when recent activity is identical', () => {
|
||||
const alpha = makeWorktree({
|
||||
id: 'alpha',
|
||||
displayName: 'Alpha',
|
||||
sortOrder: NOW + 50_000,
|
||||
describe('smart sort — Class 4 ordering', () => {
|
||||
it('breaks ties on effectiveRecentActivity, then displayName', () => {
|
||||
const recentlyActive = makeWorktree({
|
||||
id: 'recently-active',
|
||||
displayName: 'Z-Recent',
|
||||
lastActivityAt: NOW - 60_000
|
||||
})
|
||||
const beta = makeWorktree({
|
||||
id: 'beta',
|
||||
displayName: 'Beta',
|
||||
sortOrder: NOW - 50_000,
|
||||
const lessRecentlyActive = makeWorktree({
|
||||
id: 'older',
|
||||
displayName: 'A-Older',
|
||||
lastActivityAt: NOW - 10 * 60_000
|
||||
})
|
||||
const tabs = {
|
||||
[recentlyActive.id]: [makeTab({ id: 'tab-r', worktreeId: recentlyActive.id })],
|
||||
[lessRecentlyActive.id]: [makeTab({ id: 'tab-o', worktreeId: lessRecentlyActive.id })]
|
||||
}
|
||||
const sorted = sortSmart([lessRecentlyActive, recentlyActive], tabs, {})
|
||||
// Both Class 4, recency wins despite alphabetical ordering being inverted.
|
||||
expect(sorted.map((w) => w.id)).toEqual(['recently-active', 'older'])
|
||||
})
|
||||
|
||||
it('falls back to displayName when recency is identical', () => {
|
||||
const a = makeWorktree({
|
||||
id: 'a',
|
||||
displayName: 'A-First',
|
||||
lastActivityAt: NOW - 60_000
|
||||
})
|
||||
|
||||
const worktrees = [beta, alpha]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('smart', null, repoMap, null, NOW))
|
||||
|
||||
expect(worktrees.map((worktree) => worktree.id)).toEqual(['alpha', 'beta'])
|
||||
})
|
||||
|
||||
it('prefers a worktree whose current branch has a live PR over stale linkedPR metadata', () => {
|
||||
const staleLinked = makeWorktree({
|
||||
id: 'stale-linked',
|
||||
displayName: 'Stale Linked',
|
||||
branch: 'refs/heads/no-pr-anymore',
|
||||
linkedPR: 17
|
||||
})
|
||||
const livePR = makeWorktree({
|
||||
id: 'live-pr',
|
||||
displayName: 'Live PR',
|
||||
branch: 'refs/heads/has-pr-now'
|
||||
})
|
||||
const worktrees = [staleLinked, livePR]
|
||||
const prCache = {
|
||||
'/tmp/repo-1::no-pr-anymore': {
|
||||
data: null,
|
||||
fetchedAt: NOW
|
||||
},
|
||||
'/tmp/repo-1::has-pr-now': {
|
||||
data: { number: 42 },
|
||||
fetchedAt: NOW
|
||||
}
|
||||
}
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('smart', null, repoMap, prCache, NOW))
|
||||
|
||||
expect(worktrees.map((worktree) => worktree.id)).toEqual(['live-pr', 'stale-linked'])
|
||||
})
|
||||
|
||||
it('keeps linkedPR ordering when branch PR cache has not been fetched yet', () => {
|
||||
const coldCache = makeWorktree({
|
||||
id: 'cold-cache',
|
||||
displayName: 'Cold Cache',
|
||||
branch: 'refs/heads/not-fetched-yet',
|
||||
linkedPR: 17
|
||||
})
|
||||
const plain = makeWorktree({
|
||||
id: 'plain',
|
||||
displayName: 'Plain',
|
||||
branch: 'refs/heads/plain'
|
||||
})
|
||||
const worktrees = [plain, coldCache]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('smart', null, repoMap, {}, NOW))
|
||||
|
||||
expect(worktrees.map((worktree) => worktree.id)).toEqual(['cold-cache', 'plain'])
|
||||
})
|
||||
|
||||
it('can freeze the active worktree recent signals without blocking background reordering', () => {
|
||||
const activeBeforeClick = makeWorktree({
|
||||
id: 'active',
|
||||
displayName: 'Active',
|
||||
isUnread: true,
|
||||
lastActivityAt: NOW - 30_000
|
||||
})
|
||||
const activeAfterClick = { ...activeBeforeClick, isUnread: false }
|
||||
const background = makeWorktree({
|
||||
id: 'background',
|
||||
displayName: 'Background',
|
||||
const b = makeWorktree({
|
||||
id: 'b',
|
||||
displayName: 'B-Second',
|
||||
lastActivityAt: NOW - 60_000
|
||||
})
|
||||
const worktrees = [background, activeAfterClick]
|
||||
const tabsByWorktree = {
|
||||
[background.id]: [makeTab({ worktreeId: background.id, title: 'Claude Code - working' })]
|
||||
const tabs = {
|
||||
[a.id]: [makeTab({ id: 'tab-a', worktreeId: a.id })],
|
||||
[b.id]: [makeTab({ id: 'tab-b', worktreeId: b.id })]
|
||||
}
|
||||
const smartSortOverrides: Record<string, SmartSortOverride> = {
|
||||
[activeAfterClick.id]: {
|
||||
worktree: activeBeforeClick,
|
||||
tabs: [],
|
||||
hasRecentPRSignal: false
|
||||
}
|
||||
}
|
||||
|
||||
worktrees.sort(
|
||||
buildWorktreeComparator('smart', tabsByWorktree, repoMap, null, NOW, smartSortOverrides)
|
||||
)
|
||||
|
||||
expect(worktrees.map((worktree) => worktree.id)).toEqual(['background', 'active'])
|
||||
const sorted = sortSmart([b, a], tabs, {})
|
||||
expect(sorted.map((w) => w.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('can keep the active worktree in place while its unread badge is cleared on selection', () => {
|
||||
const activeBeforeClick = makeWorktree({
|
||||
id: 'active',
|
||||
displayName: 'Active',
|
||||
isUnread: true,
|
||||
lastActivityAt: NOW - 30_000
|
||||
})
|
||||
const activeAfterClick = { ...activeBeforeClick, isUnread: false }
|
||||
const background = makeWorktree({
|
||||
id: 'background',
|
||||
displayName: 'Background',
|
||||
lastActivityAt: NOW - 2 * 60_000
|
||||
})
|
||||
const worktrees = [background, activeAfterClick]
|
||||
const smartSortOverrides: Record<string, SmartSortOverride> = {
|
||||
[activeAfterClick.id]: {
|
||||
worktree: activeBeforeClick,
|
||||
tabs: [],
|
||||
hasRecentPRSignal: false
|
||||
}
|
||||
}
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('smart', null, repoMap, null, NOW, smartSortOverrides))
|
||||
|
||||
expect(worktrees.map((worktree) => worktree.id)).toEqual(['active', 'background'])
|
||||
})
|
||||
|
||||
it('keeps a more recent worktree ahead even without an override', () => {
|
||||
const activeAfterClick = makeWorktree({
|
||||
id: 'active',
|
||||
displayName: 'Active',
|
||||
isUnread: false,
|
||||
lastActivityAt: NOW - 30_000
|
||||
})
|
||||
const background = makeWorktree({
|
||||
id: 'background',
|
||||
displayName: 'Background',
|
||||
lastActivityAt: NOW - 2 * 60_000
|
||||
})
|
||||
const worktrees = [background, activeAfterClick]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('smart', null, repoMap, null, NOW))
|
||||
|
||||
expect(worktrees.map((worktree) => worktree.id)).toEqual(['active', 'background'])
|
||||
})
|
||||
|
||||
it('ranks a just-created worktree above shutdown worktrees with passive signals', () => {
|
||||
const justCreated = makeWorktree({
|
||||
id: 'new',
|
||||
displayName: 'New',
|
||||
it('honors the create-grace floor for new worktrees in Class 4', () => {
|
||||
const fresh = makeWorktree({
|
||||
id: 'fresh',
|
||||
displayName: 'Z-Fresh',
|
||||
createdAt: NOW,
|
||||
lastActivityAt: NOW
|
||||
})
|
||||
// Shutdown worktree with max passive signals but no recent activity
|
||||
const shutdown = makeWorktree({
|
||||
id: 'shutdown',
|
||||
displayName: 'Shutdown',
|
||||
isUnread: true,
|
||||
linkedIssue: 42,
|
||||
lastActivityAt: NOW - 2 * 24 * 60 * 60 * 1000
|
||||
const bumped = makeWorktree({
|
||||
id: 'bumped',
|
||||
displayName: 'A-Bumped',
|
||||
lastActivityAt: NOW + 100
|
||||
})
|
||||
const prCache = {
|
||||
'/tmp/repo-1::shutdown': {
|
||||
data: { number: 17 },
|
||||
fetchedAt: NOW
|
||||
}
|
||||
const tabs = {
|
||||
[fresh.id]: [makeTab({ id: 'tab-fresh', worktreeId: fresh.id })],
|
||||
[bumped.id]: [makeTab({ id: 'tab-bumped', worktreeId: bumped.id })]
|
||||
}
|
||||
const worktrees = [shutdown, justCreated]
|
||||
const sorted = sortSmart([bumped, fresh], tabs, {})
|
||||
// Grace floor (createdAt + 5min) lifts fresh above the slightly-newer bump.
|
||||
expect(sorted.map((w) => w.id)).toEqual(['fresh', 'bumped'])
|
||||
})
|
||||
})
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('smart', null, repoMap, prCache, NOW))
|
||||
describe('smart sort — multi-pane resolution', () => {
|
||||
it('any blocked pane promotes the whole worktree to Class 1', () => {
|
||||
const splitWorktree = makeWorktree({ id: 'split', displayName: 'Split' })
|
||||
const otherDone = makeWorktree({ id: 'other-done', displayName: 'OtherDone' })
|
||||
const tabs = {
|
||||
[splitWorktree.id]: [makeTab({ id: 'tab-split', worktreeId: splitWorktree.id })],
|
||||
[otherDone.id]: [makeTab({ id: 'tab-other', worktreeId: otherDone.id })]
|
||||
}
|
||||
const entries = {
|
||||
'tab-split:1': makeEntry({
|
||||
paneKey: 'tab-split:1',
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
}),
|
||||
'tab-split:2': makeEntry({
|
||||
paneKey: 'tab-split:2',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 30_000,
|
||||
updatedAt: NOW - 1_000
|
||||
}),
|
||||
'tab-other:1': makeEntry({
|
||||
paneKey: 'tab-other:1',
|
||||
state: 'done',
|
||||
stateStartedAt: NOW - 5_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
}
|
||||
const sorted = sortSmart([otherDone, splitWorktree], tabs, entries)
|
||||
expect(sorted.map((w) => w.id)).toEqual(['split', 'other-done'])
|
||||
})
|
||||
})
|
||||
|
||||
expect(worktrees.map((worktree) => worktree.id)).toEqual(['new', 'shutdown'])
|
||||
describe('sortWorktreesSmart — cold start fallback', () => {
|
||||
it('falls back to persisted sortOrder when no PTY is alive', () => {
|
||||
const a = makeWorktree({ id: 'a', displayName: 'A', sortOrder: 1 })
|
||||
const b = makeWorktree({ id: 'b', displayName: 'B', sortOrder: 2 })
|
||||
// No tabs, no PTYs — cold start path.
|
||||
const sorted = sortWorktreesSmart([a, b], {}, repoMap, {})
|
||||
// Higher sortOrder wins on cold start.
|
||||
expect(sorted.map((w) => w.id)).toEqual(['b', 'a'])
|
||||
})
|
||||
|
||||
it('uses the smart comparator once a PTY is alive', () => {
|
||||
const blocked = makeWorktree({ id: 'blocked', displayName: 'Blocked', sortOrder: 0 })
|
||||
const done = makeWorktree({ id: 'done', displayName: 'Done', sortOrder: 100 })
|
||||
const tabsByWorktree = {
|
||||
[blocked.id]: [makeTab({ id: 'tab-blocked', worktreeId: blocked.id })],
|
||||
[done.id]: [makeTab({ id: 'tab-done', worktreeId: done.id })]
|
||||
}
|
||||
const entries = {
|
||||
'tab-blocked:1': makeEntry({
|
||||
paneKey: 'tab-blocked:1',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
}),
|
||||
'tab-done:1': makeEntry({
|
||||
paneKey: 'tab-done:1',
|
||||
state: 'done',
|
||||
stateStartedAt: NOW - 30_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
}
|
||||
const sorted = sortWorktreesSmart([done, blocked], tabsByWorktree, repoMap, entries)
|
||||
// Smart comparator wins over sortOrder because at least one PTY is live.
|
||||
expect(sorted.map((w) => w.id)).toEqual(['blocked', 'done'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortWorktreesSmart — palette caller regression', () => {
|
||||
// Why: WorktreeJumpPalette routes typed queries through sortWorktreesSmart.
|
||||
// This test pins that the palette path uses the class layer (not the
|
||||
// dropped terminal-title heuristic) when threading agentStatusByPaneKey.
|
||||
it('palette ranks blocked above working when both flow through sortWorktreesSmart', () => {
|
||||
const blocked = makeWorktree({ id: 'blocked', displayName: 'A-Blocked' })
|
||||
const working = makeWorktree({ id: 'working', displayName: 'B-Working' })
|
||||
const tabsByWorktree = {
|
||||
[blocked.id]: [makeTab({ id: 'tab-blocked', worktreeId: blocked.id })],
|
||||
[working.id]: [makeTab({ id: 'tab-working', worktreeId: working.id })]
|
||||
}
|
||||
const agentStatusByPaneKey: Record<string, AgentStatusEntry> = {
|
||||
'tab-blocked:1': makeEntry({
|
||||
paneKey: 'tab-blocked:1',
|
||||
state: 'blocked',
|
||||
stateStartedAt: NOW - 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
}),
|
||||
'tab-working:1': makeEntry({
|
||||
paneKey: 'tab-working:1',
|
||||
state: 'working',
|
||||
// newer than the blocked one — would win on recency alone
|
||||
stateStartedAt: NOW - 1_000,
|
||||
updatedAt: NOW - 500
|
||||
})
|
||||
}
|
||||
const sorted = sortWorktreesSmart(
|
||||
[working, blocked],
|
||||
tabsByWorktree,
|
||||
repoMap,
|
||||
agentStatusByPaneKey
|
||||
)
|
||||
expect(sorted.map((w) => w.id)).toEqual(['blocked', 'working'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -455,7 +511,7 @@ describe('buildWorktreeComparator — recent (lastActivityAt)', () => {
|
||||
})
|
||||
const worktrees = [older, newer]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW))
|
||||
worktrees.sort(buildWorktreeComparator('recent', repoMap, NOW))
|
||||
|
||||
expect(worktrees.map((w) => w.id)).toEqual(['newer', 'older'])
|
||||
})
|
||||
@@ -473,7 +529,7 @@ describe('buildWorktreeComparator — recent (lastActivityAt)', () => {
|
||||
})
|
||||
const worktrees = [legacy, touched]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW))
|
||||
worktrees.sort(buildWorktreeComparator('recent', repoMap, NOW))
|
||||
|
||||
expect(worktrees.map((w) => w.id)).toEqual(['touched', 'legacy'])
|
||||
})
|
||||
@@ -491,14 +547,12 @@ describe('buildWorktreeComparator — recent (lastActivityAt)', () => {
|
||||
})
|
||||
const worktrees = [bravo, alpha]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW))
|
||||
worktrees.sort(buildWorktreeComparator('recent', repoMap, NOW))
|
||||
|
||||
expect(worktrees.map((w) => w.id)).toEqual(['alpha', 'bravo'])
|
||||
})
|
||||
|
||||
it('ignores sortOrder entirely — activity alone determines the order', () => {
|
||||
// A worktree with a stale high sortOrder (e.g. baked in when meta was
|
||||
// first created) must not outrank a worktree with fresher activity.
|
||||
const staleHighOrder = makeWorktree({
|
||||
id: 'stale-high-order',
|
||||
displayName: 'Orca main',
|
||||
@@ -513,7 +567,7 @@ describe('buildWorktreeComparator — recent (lastActivityAt)', () => {
|
||||
})
|
||||
const worktrees = [staleHighOrder, freshActive]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW))
|
||||
worktrees.sort(buildWorktreeComparator('recent', repoMap, NOW))
|
||||
|
||||
expect(worktrees.map((w) => w.id)).toEqual(['fresh-active', 'stale-high-order'])
|
||||
})
|
||||
@@ -540,19 +594,12 @@ describe('effectiveRecentActivity — create-grace floor', () => {
|
||||
})
|
||||
|
||||
it('returns lastActivityAt when real activity has surpassed the grace floor', () => {
|
||||
// A user who interacted 3 minutes after create has lastActivityAt > createdAt + 3min,
|
||||
// but createdAt + 5min still wins for the next 2 minutes.
|
||||
const createdAt = NOW - 3 * 60 * 1000
|
||||
const wt = makeWorktree({ id: 'used', createdAt, lastActivityAt: NOW - 60_000 })
|
||||
// createdAt + GRACE_MS = NOW + 2min, which exceeds lastActivityAt (NOW - 1min).
|
||||
expect(effectiveRecentActivity(wt, NOW)).toBe(createdAt + CREATE_GRACE_MS)
|
||||
})
|
||||
|
||||
it('returns lastActivityAt once the grace window has elapsed even when no other activity has occurred', () => {
|
||||
// Bug-fix case: a worktree created days ago that was never touched after
|
||||
// creation. Without the time-bound check, the floor would still apply and
|
||||
// the worktree would rank as `createdAt + 5min` forever, masking truly
|
||||
// fresher worktrees.
|
||||
const createdAt = NOW - CREATE_GRACE_MS - 1
|
||||
const wt = makeWorktree({ id: 'untouched', createdAt, lastActivityAt: createdAt })
|
||||
expect(effectiveRecentActivity(wt, NOW)).toBe(createdAt)
|
||||
@@ -561,9 +608,6 @@ describe('effectiveRecentActivity — create-grace floor', () => {
|
||||
|
||||
describe('buildWorktreeComparator — recent with createdAt grace window', () => {
|
||||
it('keeps a newly-created worktree on top even when another worktree bumps lastActivityAt', () => {
|
||||
// Simulates the bug: user creates a worktree at t=0, then an ambient PTY
|
||||
// bump on a different worktree lands at t=+100ms. Without the grace
|
||||
// window, the bumped worktree would outrank the new one by 100ms.
|
||||
const newWorktree = makeWorktree({
|
||||
id: 'new',
|
||||
displayName: 'New',
|
||||
@@ -577,7 +621,7 @@ describe('buildWorktreeComparator — recent with createdAt grace window', () =>
|
||||
})
|
||||
const worktrees = [bumpedByAmbient, newWorktree]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW))
|
||||
worktrees.sort(buildWorktreeComparator('recent', repoMap, NOW))
|
||||
|
||||
expect(worktrees.map((w) => w.id)).toEqual(['new', 'bumped'])
|
||||
})
|
||||
@@ -586,31 +630,27 @@ describe('buildWorktreeComparator — recent with createdAt grace window', () =>
|
||||
const oldCreated = makeWorktree({
|
||||
id: 'old-created',
|
||||
displayName: 'Old created',
|
||||
// Created longer ago than GRACE_MS so the floor has expired.
|
||||
createdAt: NOW - CREATE_GRACE_MS - 10_000,
|
||||
lastActivityAt: NOW - 30_000
|
||||
})
|
||||
const freshActivity = makeWorktree({
|
||||
id: 'fresh-activity',
|
||||
displayName: 'Fresh activity',
|
||||
// No createdAt (discovered on disk), but has recent real activity.
|
||||
lastActivityAt: NOW - 1000
|
||||
})
|
||||
const worktrees = [oldCreated, freshActivity]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW))
|
||||
worktrees.sort(buildWorktreeComparator('recent', repoMap, NOW))
|
||||
|
||||
expect(worktrees.map((w) => w.id)).toEqual(['fresh-activity', 'old-created'])
|
||||
})
|
||||
|
||||
it('does not disturb ranking for worktrees without createdAt', () => {
|
||||
// All existing worktrees (persisted before createdAt field existed) stay
|
||||
// sorted by lastActivityAt alone.
|
||||
const alpha = makeWorktree({ id: 'alpha', displayName: 'Alpha', lastActivityAt: 5000 })
|
||||
const bravo = makeWorktree({ id: 'bravo', displayName: 'Bravo', lastActivityAt: 10_000 })
|
||||
const worktrees = [alpha, bravo]
|
||||
|
||||
worktrees.sort(buildWorktreeComparator('recent', null, repoMap, null, NOW))
|
||||
worktrees.sort(buildWorktreeComparator('recent', repoMap, NOW))
|
||||
|
||||
expect(worktrees.map((w) => w.id)).toEqual(['bravo', 'alpha'])
|
||||
})
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status'
|
||||
import { branchName } from '@/lib/git-utils'
|
||||
import type { Worktree, Repo, TerminalTab } from '../../../../shared/types'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { IDLE, buildAttentionByWorktree, type WorktreeAttention } from './smart-attention'
|
||||
|
||||
type SortBy = 'name' | 'smart' | 'recent' | 'repo'
|
||||
|
||||
@@ -40,276 +36,36 @@ export function effectiveRecentActivity(worktree: Worktree, now: number): number
|
||||
return Math.max(lastActivityAt, createdAt + CREATE_GRACE_MS)
|
||||
}
|
||||
|
||||
type PRCacheEntry = { data: object | null; fetchedAt: number }
|
||||
export type SmartSortOverride = {
|
||||
worktree: Worktree
|
||||
tabs: TerminalTab[]
|
||||
hasRecentPRSignal: boolean
|
||||
}
|
||||
|
||||
// Why: building this index once at the sort call site reduces the smart-
|
||||
// score computation from O(N × E × T) to O(E) build + O(T) lookups per
|
||||
// worktree. Before, each worktree's score computation scanned the full
|
||||
// agentStatusByPaneKey map, which made the decorate-sort-undecorate
|
||||
// precompute pay the scan N times even though the map is global. Entries are
|
||||
// keyed by the `tabId` prefix of their paneKey (paneKey format: `${tabId}:…`).
|
||||
export function buildExplicitEntriesByTabId(
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry> | undefined
|
||||
): Map<string, AgentStatusEntry[]> {
|
||||
const byTab = new Map<string, AgentStatusEntry[]>()
|
||||
if (!agentStatusByPaneKey) {
|
||||
return byTab
|
||||
}
|
||||
for (const entry of Object.values(agentStatusByPaneKey)) {
|
||||
const colon = entry.paneKey.indexOf(':')
|
||||
// Why: paneKey must be `${tabId}:${paneId}`. Skip malformed entries (no
|
||||
// colon or leading colon) rather than bucketing them under an empty tabId,
|
||||
// where they would never match a real tab and just waste memory.
|
||||
if (colon <= 0) {
|
||||
continue
|
||||
}
|
||||
const tabId = entry.paneKey.slice(0, colon)
|
||||
const bucket = byTab.get(tabId)
|
||||
if (bucket) {
|
||||
bucket.push(entry)
|
||||
} else {
|
||||
byTab.set(tabId, [entry])
|
||||
}
|
||||
}
|
||||
return byTab
|
||||
}
|
||||
|
||||
export function hasRecentPRSignal(
|
||||
worktree: Worktree,
|
||||
repoMap: Map<string, Repo>,
|
||||
prCache: Record<string, PRCacheEntry> | null
|
||||
): boolean {
|
||||
const repo = repoMap.get(worktree.repoId)
|
||||
const branch = branchName(worktree.branch)
|
||||
if (!repo || !branch) {
|
||||
return worktree.linkedPR !== null
|
||||
}
|
||||
|
||||
const cacheKey = `${repo.path}::${branch}`
|
||||
const cachedEntry = prCache?.[cacheKey]
|
||||
if (cachedEntry) {
|
||||
return Boolean(cachedEntry.data)
|
||||
}
|
||||
|
||||
return worktree.linkedPR !== null
|
||||
}
|
||||
|
||||
function computeSmartScoreFromSignals(
|
||||
worktree: Worktree,
|
||||
tabs: TerminalTab[],
|
||||
hasRecentPR: boolean,
|
||||
now: number,
|
||||
agentStatusByPaneKey?: Record<string, AgentStatusEntry>,
|
||||
explicitByTabId?: Map<string, AgentStatusEntry[]>
|
||||
): number {
|
||||
const liveTabs = tabs.filter((t) => t.ptyId)
|
||||
|
||||
let score = 0
|
||||
|
||||
// Why: explicit agent status (OSC 9999) is authoritative over heuristic title
|
||||
// parsing. Check explicit status first; fall through to heuristics for tabs
|
||||
// that have no explicit status entry.
|
||||
//
|
||||
// Why the index parameter: when the caller precomputes the tabId → entries
|
||||
// index once (via buildExplicitEntriesByTabId) and threads it through, each
|
||||
// worktree does O(T) lookups instead of scanning the full map O(E) times.
|
||||
// This matters because `sortWorktreesSmart` calls this function N times in a
|
||||
// decorate-sort-undecorate pass; without the shared index we'd pay O(N×E×T)
|
||||
// overall. When the index is absent and `agentStatusByPaneKey` is provided
|
||||
// we build it inline to preserve backward compatibility for callers (tests,
|
||||
// palette) that haven't adopted the optimization.
|
||||
const resolvedExplicitByTabId =
|
||||
explicitByTabId ?? buildExplicitEntriesByTabId(agentStatusByPaneKey)
|
||||
|
||||
let hasExplicitWorking = false
|
||||
let hasExplicitBlocked = false
|
||||
let hasHeuristicWorking = false
|
||||
let hasHeuristicBlocked = false
|
||||
|
||||
for (const tab of liveTabs) {
|
||||
const tabExplicitEntries = resolvedExplicitByTabId.get(tab.id) ?? []
|
||||
// Why: compute freshness once per entry instead of recomputing inside each
|
||||
// of the three `.some(...)` passes below. Freshness is a pure function of
|
||||
// (entry, now) so filtering up front is equivalent and cheaper.
|
||||
const freshEntries = tabExplicitEntries.filter((entry) =>
|
||||
isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)
|
||||
)
|
||||
|
||||
if (freshEntries.length > 0) {
|
||||
hasExplicitWorking ||= freshEntries.some((entry) => entry.state === 'working')
|
||||
hasExplicitBlocked ||= freshEntries.some(
|
||||
(entry) => entry.state === 'blocked' || entry.state === 'waiting'
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const heuristicState = detectAgentStatusFromTitle(tab.title)
|
||||
hasHeuristicWorking ||= heuristicState === 'working'
|
||||
hasHeuristicBlocked ||= heuristicState === 'permission'
|
||||
}
|
||||
|
||||
// Explicit working → +60, same weight as heuristic working
|
||||
// Explicit blocked/waiting → +35, same weight as heuristic permission
|
||||
// Explicit done → no bonus (task complete, no attention needed)
|
||||
const isRunning = hasExplicitWorking || hasHeuristicWorking
|
||||
if (isRunning) {
|
||||
score += 60
|
||||
}
|
||||
|
||||
const needsAttention = hasExplicitBlocked || hasHeuristicBlocked
|
||||
if (needsAttention) {
|
||||
score += 35
|
||||
}
|
||||
|
||||
if (worktree.isUnread) {
|
||||
score += 18
|
||||
}
|
||||
|
||||
if (liveTabs.length > 0) {
|
||||
score += 12
|
||||
}
|
||||
|
||||
if (hasRecentPR) {
|
||||
score += 10
|
||||
}
|
||||
|
||||
if (worktree.linkedIssue !== null) {
|
||||
score += 6
|
||||
}
|
||||
|
||||
const activityAge = now - (worktree.lastActivityAt || 0)
|
||||
if (worktree.lastActivityAt > 0) {
|
||||
const ONE_DAY = 24 * 60 * 60 * 1000
|
||||
// Why 36: a just-created worktree has only this signal (no live tab yet,
|
||||
// since the PTY spawns asynchronously after creation). Weight must exceed
|
||||
// the max passive-signal combination for shutdown worktrees
|
||||
// (isUnread 18 + PR 10 + issue 6 = 34) so brand-new worktrees always
|
||||
// appear at the top of the "smart" sort immediately.
|
||||
score += 36 * Math.max(0, 1 - activityAge / ONE_DAY)
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
function getSmartSortCandidate(
|
||||
worktree: Worktree,
|
||||
tabsByWorktree: Record<string, TerminalTab[]> | null,
|
||||
repoMap: Map<string, Repo>,
|
||||
prCache: Record<string, PRCacheEntry> | null,
|
||||
smartSortOverrides: Record<string, SmartSortOverride> | null
|
||||
): SmartSortOverride {
|
||||
return (
|
||||
smartSortOverrides?.[worktree.id] ?? {
|
||||
worktree,
|
||||
tabs: tabsByWorktree?.[worktree.id] ?? [],
|
||||
hasRecentPRSignal: hasRecentPRSignal(worktree, repoMap, prCache)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a comparator for sorting worktrees based on the current sort mode.
|
||||
*
|
||||
* `precomputedScores` is the decorate-sort-undecorate optimization for the
|
||||
* `smart` mode: callers should compute each worktree's smart score once and
|
||||
* pass the map in, since `Array.prototype.sort` invokes the comparator
|
||||
* O(N log N) times and recomputing the score each call scans the
|
||||
* `agentStatusByPaneKey` map O(N log N × E) times. When omitted, the
|
||||
* comparator falls back to computing scores per-comparison so existing call
|
||||
* sites that haven't adopted the optimization keep working.
|
||||
*
|
||||
* `explicitByTabId` is a secondary optimization: when `precomputedScores` is
|
||||
* absent (fallback path), the comparator still has to call
|
||||
* `computeSmartScoreFromSignals` per comparison. Passing the prebuilt tabId
|
||||
* index avoids rescanning the full `agentStatusByPaneKey` map on every call.
|
||||
* When this index is also absent, the inner function builds one inline per
|
||||
* invocation to preserve backward compatibility.
|
||||
* Smart mode requires `attentionByWorktree` — a per-worktree class +
|
||||
* timestamp map built once before sorting (see `buildAttentionByWorktree`).
|
||||
* Without it, every worktree collapses to Class 4 (idle) and the comparator
|
||||
* silently degrades to recent-activity ordering, so callers MUST thread the
|
||||
* map for smart mode.
|
||||
*/
|
||||
export function buildWorktreeComparator(
|
||||
sortBy: SortBy,
|
||||
tabsByWorktree: Record<string, TerminalTab[]> | null,
|
||||
repoMap: Map<string, Repo>,
|
||||
prCache: Record<string, PRCacheEntry> | null,
|
||||
now: number = Date.now(),
|
||||
smartSortOverrides: Record<string, SmartSortOverride> | null = null,
|
||||
agentStatusByPaneKey?: Record<string, AgentStatusEntry>,
|
||||
precomputedScores?: Map<string, number>,
|
||||
explicitByTabId?: Map<string, AgentStatusEntry[]>
|
||||
attentionByWorktree: Map<string, WorktreeAttention> | null = null
|
||||
): (a: Worktree, b: Worktree) => number {
|
||||
// Why: when the caller does not pre-build the tabId index but does provide
|
||||
// the source map, build it ONCE here and close over it. Array.sort invokes
|
||||
// the comparator O(N log N) times in the fallback path (no precomputed
|
||||
// scores), and `computeSmartScoreFromSignals` would otherwise rebuild the
|
||||
// O(E) index on every comparison — re-introducing the O(N log N × E) cost
|
||||
// the precompute was meant to avoid. Only matters for the smart mode; for
|
||||
// other modes we skip construction.
|
||||
const resolvedExplicitByTabId =
|
||||
sortBy === 'smart' && !explicitByTabId && agentStatusByPaneKey
|
||||
? buildExplicitEntriesByTabId(agentStatusByPaneKey)
|
||||
: explicitByTabId
|
||||
|
||||
return (a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return a.displayName.localeCompare(b.displayName)
|
||||
case 'smart': {
|
||||
const smartA = getSmartSortCandidate(
|
||||
a,
|
||||
tabsByWorktree,
|
||||
repoMap,
|
||||
prCache,
|
||||
smartSortOverrides
|
||||
)
|
||||
const smartB = getSmartSortCandidate(
|
||||
b,
|
||||
tabsByWorktree,
|
||||
repoMap,
|
||||
prCache,
|
||||
smartSortOverrides
|
||||
)
|
||||
// Why precomputedScores: the smart-score computation iterates
|
||||
// `agentStatusByPaneKey` (O(E) per call) when no tabId index is
|
||||
// threaded in, and still does O(T) lookups per worktree when one is.
|
||||
// The comparator is invoked O(N log N) times by Array.sort, so without
|
||||
// memoization we pay O(N log N × E) (or O(N log N × T) with the
|
||||
// index). When the caller supplies a precomputed score map we get
|
||||
// O(1) lookups; when it doesn't we preserve the old behavior and pass
|
||||
// the optional `explicitByTabId` index to the inner function so the
|
||||
// fallback path avoids the full-map scan as well. Overrides bypass the
|
||||
// precomputed map because the override intentionally freezes the
|
||||
// candidate's inputs (tabs, hasRecentPRSignal) which may differ from
|
||||
// the live score.
|
||||
const scoreA =
|
||||
precomputedScores && !smartSortOverrides?.[a.id]
|
||||
? (precomputedScores.get(a.id) ?? 0)
|
||||
: computeSmartScoreFromSignals(
|
||||
smartA.worktree,
|
||||
smartA.tabs,
|
||||
smartA.hasRecentPRSignal,
|
||||
now,
|
||||
agentStatusByPaneKey,
|
||||
resolvedExplicitByTabId
|
||||
)
|
||||
const scoreB =
|
||||
precomputedScores && !smartSortOverrides?.[b.id]
|
||||
? (precomputedScores.get(b.id) ?? 0)
|
||||
: computeSmartScoreFromSignals(
|
||||
smartB.worktree,
|
||||
smartB.tabs,
|
||||
smartB.hasRecentPRSignal,
|
||||
now,
|
||||
agentStatusByPaneKey,
|
||||
resolvedExplicitByTabId
|
||||
)
|
||||
const aw = attentionByWorktree?.get(a.id) ?? IDLE
|
||||
const bw = attentionByWorktree?.get(b.id) ?? IDLE
|
||||
return (
|
||||
scoreB - scoreA ||
|
||||
effectiveRecentActivity(smartB.worktree, now) -
|
||||
effectiveRecentActivity(smartA.worktree, now) ||
|
||||
// Why: 1 < 2 < 3 < 4 — lower class outranks higher.
|
||||
aw.cls - bw.cls ||
|
||||
// Why: within a class, the more recent attention event ranks first.
|
||||
bw.attentionTimestamp - aw.attentionTimestamp ||
|
||||
// Why: idle worktrees fall through to recency (and the create-grace
|
||||
// floor for brand-new worktrees) before alphabetical.
|
||||
effectiveRecentActivity(b, now) - effectiveRecentActivity(a, now) ||
|
||||
a.displayName.localeCompare(b.displayName)
|
||||
)
|
||||
}
|
||||
@@ -344,113 +100,43 @@ export function buildWorktreeComparator(
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort worktrees by weighted smart-score signals, handling the cold-start /
|
||||
* warm distinction in one place. On cold start (no live PTYs yet), falls back
|
||||
* to persisted `sortOrder` descending with alphabetical `displayName` fallback.
|
||||
* Once any PTY is alive, uses the full smart-score comparator.
|
||||
* Sort worktrees by the smart-attention comparator (status class first,
|
||||
* recency-of-attention second). On cold start (no live PTYs yet), falls back
|
||||
* to persisted `sortOrder` descending so the sidebar restores the pre-quit
|
||||
* order until the agent-status snapshot lands.
|
||||
*
|
||||
* Both the palette and `getVisibleWorktreeIds()` import this to avoid
|
||||
* duplicating the cold/warm branching logic.
|
||||
*
|
||||
* `agentStatusByPaneKey` is required: the comparator depends on it for class
|
||||
* resolution, and a forgotten caller would silently regress every worktree to
|
||||
* Class 4.
|
||||
*/
|
||||
export function sortWorktreesSmart(
|
||||
worktrees: Worktree[],
|
||||
tabsByWorktree: Record<string, TerminalTab[]>,
|
||||
repoMap: Map<string, Repo>,
|
||||
prCache: Record<string, PRCacheEntry> | null,
|
||||
agentStatusByPaneKey?: Record<string, AgentStatusEntry>
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>
|
||||
): Worktree[] {
|
||||
const hasAnyLivePty = Object.values(tabsByWorktree)
|
||||
.flat()
|
||||
.some((t) => t.ptyId)
|
||||
|
||||
if (!hasAnyLivePty) {
|
||||
// Cold start: use persisted sortOrder snapshot
|
||||
// Cold start: use persisted sortOrder snapshot until the agent-status
|
||||
// snapshot lands and a warm sort runs.
|
||||
return [...worktrees].sort(
|
||||
(a, b) => b.sortOrder - a.sortOrder || a.displayName.localeCompare(b.displayName)
|
||||
)
|
||||
}
|
||||
|
||||
// Why precompute: Array.sort calls the comparator O(N log N) times and the
|
||||
// smart-score computation needs to look up explicit-status entries per tab.
|
||||
// We apply two layered optimizations:
|
||||
// 1. Build the `explicitByTabId` index ONCE up front (O(E) work). Without
|
||||
// it, each per-worktree score would scan the full `agentStatusByPaneKey`
|
||||
// map to find matching entries, which is O(N × E × T) across all
|
||||
// worktrees — the same cost the decorate-sort-undecorate pass was
|
||||
// supposed to avoid.
|
||||
// 2. Precompute each worktree's score once (decorate-sort-undecorate) so
|
||||
// the comparator does O(1) map lookups instead of re-scoring per
|
||||
// comparison.
|
||||
// Combined cost: O(E) index build + O(N × T) scoring + O(N log N) sort,
|
||||
// instead of the prior O(N × E × T + N log N).
|
||||
const now = Date.now()
|
||||
const explicitByTabId = buildExplicitEntriesByTabId(agentStatusByPaneKey)
|
||||
const precomputedScores = new Map<string, number>(
|
||||
worktrees.map((w) => [
|
||||
w.id,
|
||||
computeSmartScore(
|
||||
w,
|
||||
tabsByWorktree,
|
||||
repoMap,
|
||||
prCache,
|
||||
now,
|
||||
agentStatusByPaneKey,
|
||||
explicitByTabId
|
||||
)
|
||||
])
|
||||
)
|
||||
|
||||
// Why: agentStatusByPaneKey is forwarded so the smart-score comparator can
|
||||
// use explicit agent status (OSC 9999) when ranking worktrees by recency.
|
||||
// `explicitByTabId` is forwarded too so the comparator's fallback path (used
|
||||
// for worktrees covered by smartSortOverrides) avoids rebuilding the index.
|
||||
return [...worktrees].sort(
|
||||
buildWorktreeComparator(
|
||||
'smart',
|
||||
tabsByWorktree,
|
||||
repoMap,
|
||||
prCache,
|
||||
now,
|
||||
null,
|
||||
agentStatusByPaneKey,
|
||||
precomputedScores,
|
||||
explicitByTabId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a recent-work score for a worktree.
|
||||
* Higher score = higher in the list.
|
||||
*
|
||||
* Scoring:
|
||||
* running AI job → +60
|
||||
* recent activity → +36 (decays over 24 hours)
|
||||
* needs attention → +35
|
||||
* unread → +18
|
||||
* open terminal → +12
|
||||
* live branch PR → +10
|
||||
* linked issue → +6
|
||||
*/
|
||||
export function computeSmartScore(
|
||||
worktree: Worktree,
|
||||
tabsByWorktree: Record<string, TerminalTab[]> | null,
|
||||
repoMap: Map<string, Repo> | null,
|
||||
prCache: Record<string, PRCacheEntry> | null,
|
||||
now: number = Date.now(),
|
||||
agentStatusByPaneKey?: Record<string, AgentStatusEntry>,
|
||||
explicitByTabId?: Map<string, AgentStatusEntry[]>
|
||||
): number {
|
||||
return computeSmartScoreFromSignals(
|
||||
worktree,
|
||||
tabsByWorktree?.[worktree.id] ?? [],
|
||||
// Why: branch-aware PR cache is the freshest signal, but off-screen
|
||||
// worktrees may not have fetched it yet. Fall back to persisted linkedPR
|
||||
// only while that branch cache entry is still cold so smart sorting stays
|
||||
// stable on launch without reviving stale PRs after a cache miss resolves.
|
||||
repoMap ? hasRecentPRSignal(worktree, repoMap, prCache) : worktree.linkedPR !== null,
|
||||
now,
|
||||
const attentionByWorktree = buildAttentionByWorktree(
|
||||
worktrees,
|
||||
tabsByWorktree,
|
||||
agentStatusByPaneKey,
|
||||
explicitByTabId
|
||||
now
|
||||
)
|
||||
|
||||
return [...worktrees].sort(buildWorktreeComparator('smart', repoMap, now, attentionByWorktree))
|
||||
}
|
||||
|
||||
@@ -180,20 +180,11 @@ export function getVisibleWorktreeIds(): string[] {
|
||||
allWorktrees,
|
||||
state.tabsByWorktree,
|
||||
repoMap,
|
||||
state.prCache,
|
||||
state.agentStatusByPaneKey
|
||||
).map((w) => w.id)
|
||||
} else {
|
||||
const sorted = [...allWorktrees].sort(
|
||||
buildWorktreeComparator(
|
||||
state.sortBy,
|
||||
state.tabsByWorktree,
|
||||
repoMap,
|
||||
state.prCache,
|
||||
Date.now(),
|
||||
null,
|
||||
state.agentStatusByPaneKey
|
||||
)
|
||||
buildWorktreeComparator(state.sortBy, repoMap, Date.now())
|
||||
)
|
||||
sortedIds = sorted.map((w) => w.id)
|
||||
}
|
||||
|
||||
@@ -207,15 +207,15 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
// on the other hand, bumps only when sort-relevant inputs change —
|
||||
// avoiding sidebar re-sorts on every tool/prompt event would stress
|
||||
// the smart-sort debounce for no reason. Sort-relevant inputs are:
|
||||
// 1. `state` transitions — sort score is a function of state.
|
||||
// 2. Freshness transitions (stale → fresh) — `computeSmartScoreFromSignals`
|
||||
// in smart-sort.ts filters entries through
|
||||
// 1. `state` transitions — smart-sort class is a function of state.
|
||||
// 2. Freshness transitions (stale → fresh) — `resolveAttention` in
|
||||
// smart-attention.ts filters entries through
|
||||
// `isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)`
|
||||
// (30-min TTL). A stale entry that refreshes with the SAME state
|
||||
// goes from "not contributing" to contributing +60 (working) or
|
||||
// +35 (blocked/waiting) to the score — order must update. The new
|
||||
// entry below always has `updatedAt = now`, so it is fresh; we
|
||||
// only need to detect the stale→fresh flip on `existing`.
|
||||
// goes from "not contributing" (Class 4) to driving a higher
|
||||
// class — order must update. The new entry below always has
|
||||
// `updatedAt = now`, so it is fresh; we only need to detect the
|
||||
// stale→fresh flip on `existing`.
|
||||
const wasFresh =
|
||||
!!existing && isExplicitAgentStatusFresh(existing, now, AGENT_STATUS_STALE_AFTER_MS)
|
||||
const sortRelevantChange = !existing || existing.state !== payload.state || !wasFresh
|
||||
@@ -537,8 +537,9 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
dismissRetainedAgent: (paneKey) => {
|
||||
// Why: no agentStatusEpoch / sortEpoch bump here (mirrors retainAgents).
|
||||
// Retained rows are a pure read-overlay on top of agentStatusByPaneKey —
|
||||
// they do not contribute to smart-sort scoring (see computeSmartScore*
|
||||
// in smart-sort.ts, which reads agentStatusByPaneKey only) and dashboard
|
||||
// they do not contribute to smart-sort class resolution (see
|
||||
// resolveAttention in smart-attention.ts, which reads
|
||||
// agentStatusByPaneKey only) and dashboard
|
||||
// selectors re-render on retainedAgentsByPaneKey identity changes
|
||||
// directly. Bumping epochs would force sidebar re-sorts and selector
|
||||
// recomputations for a change that cannot affect either result.
|
||||
|
||||
@@ -403,7 +403,7 @@ describe('setActiveWorktree', () => {
|
||||
|
||||
const worktrees = [...store.getState().worktreesByRepo.repo1]
|
||||
const repoMap = new Map(store.getState().repos.map((repo) => [repo.id, repo]))
|
||||
worktrees.sort(buildWorktreeComparator('smart', {}, repoMap, null, now))
|
||||
worktrees.sort(buildWorktreeComparator('smart', repoMap, now))
|
||||
|
||||
expect(worktrees.map((worktree) => worktree.id)).toEqual([backgroundId, focusedId])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user