SIGN IN SIGN UP

fix(ui): decide the agent rail's active state by route, not by id or exact path (#2778)

## Description

Two defects in the agent rail's active state, both about the same thing:
what the rail treats as "the page you are on".

### 1. A conversation was lit by id rather than by route

`ChatEntry`'s active state was `candidate.id === ref.id` — an id match
with no reference to the location. That is true on every surface which
mounts the rail for an instance, and one of those is the agent's own
details page. The row links to that conversation's **chat**, so on
`/agents/:id` the rail highlighted a row for a page you were not on, and
two entries in the rail could look current at once.

**Reproducing:** open a conversation, use **Agent Details** in the rail,
and its row is still highlighted on `/agents/:id` while its href points
at `/agents/:id/chat`.

`isActive` is compared against the row's own href now, which is how the
nav entries above it already decide the same thing:

```diff
-                href={url.chat({ id: candidate.id })}
-                isActive={candidate.id === ref.id}
+                href={href}
+                isActive={location.pathname === href}
```

`href` is hoisted out of the JSX so the comparison and the link cannot
drift apart. The reads beneath it keep matching on the id on purpose —
which conversation's live state to prefer is a question about the
instance, not the route. The row also gained `aria-current="page"` when
active, as `RailEntry` already had; without it the highlight was the
only thing indicating the current page.

### 2. A contributed entry could not express its active state

The extension side had the mirror-image problem — an exact string
compare:

```ts
isActive={contribution.path ? location.pathname === contribution.path : false}
```

That works for the static path the bundled example ships with and cannot
work for a parameterised one: the pattern a contribution declares is
never equal to a filled-in location, so an entry at
`/agents/:id/analytics` is unlit on the very page it leads to. Since the
rail is per-agent, a parameterised path is the normal case for anything
contributed to it.

`path` is resolved with `matchPath` now — the same thing the router
resolves routes with — so an entry is active on exactly the addresses
its route answers. A pattern with no parameters behaves as before.

`isActive` is added for what one pattern cannot say: several routes, or
a search parameter. It is the equivalent of the application's own
`alsoActiveOn`, as a function because only the contribution knows the
shape of its own addresses.

```ts
export function railItemIsActive(
  contribution: ExtensionAgentRailItemContribution,
  location: { pathname: string; search: string },
): boolean {
  if (contribution.isActive?.(location)) return true;
  return contribution.path
    ? matchPath(contribution.path, location.pathname) !== null
    : false;
}
```

**The two signals are OR-ed**, so an entry can declare where it leads
and still widen it rather than restating the path inside a function.
Worth flagging as a design decision rather than an accident: it means a
contribution can widen its active state but not narrow it — `path`
matching wins even if `isActive` returns `false`. Happy to switch to
precedence if you would rather have both directions.

## Testing

`playwright/tests/chat/agent-rail.spec.ts` gains one case for the first
defect, asserting from the details page — the chat is the one place the
old behaviour happened to be right. It checks the row is listed but not
current there, that nothing in the rail claims to be current, and that
opening the conversation is what makes it so.

The second is verified behaviourally, by pointing the bundled example's
rail entry at each signal in turn and then restoring it:

| entry declares | on `/agents/:id/chat` | on `/agents/:id` | with
`?lit=1` |
|---|---|---|---|
| `path: "/agents/:id/chat"` | active | not active | — |
| `isActive` reading pathname or search | active | not active | active |
| `path: "/example"` (as shipped) | not active | not active | — |

- `yarn typecheck` — clean
- `yarn lint` — no errors
- `npx playwright test playwright/tests/extensions/
playwright/tests/chat/ --project=chromium
--project=chromium-with-extension` — 33 passed

No unit test for the helper; the table above is the behaviour that
matters and the existing extension-point specs cover the static case.

---
*🤖 written by Claude*

---------

Signed-off-by: Nicholas Bucher <behappy54321@gmail.com>
N
Nicholas Bucher committed
32bf873f2f4aec5dbd47689a43b2d68a93b3c06d
Parent: 87bca81
Committed by GitHub <noreply@github.com> on 9/9/2026, 8:59:18 PM