Repository navigation
fix(workspace): a failed agent-page fetch says so instead of rendering an empty agent (#2597) - #2602
Conversation
…g an empty agent (#2597) `usePortalAgentPage` has always returned `error`, `loaded` and `reload`. Both consumers ignored a different half of them, and the two failures are mirror images of each other. `PortalAgentDetails` destructured `{ header, capabilities }` only, so a failed `/page` fell through to the computed fallbacks: identity degraded to the slug, the description vanished, and "This agent hasn't published anything it can do yet" appeared — a positive claim about the agent made on no evidence, and indistinguishable from a real answer. "Your chats" kept working, because it is a slice of the shell's thread list rather than of `/page`, which made the rest read as loaded instead of broken. `PortalAgentBand` reads `error` but gates its banner on `loaded`, so it covers a failed REFRESH and left a failed FIRST load as three placeholders pulsing forever, with no error and no retry. The issue cites the band as the surface that already follows the rule; it follows half of it. There are exactly two consumers of this fetch, and fixing one of two is how this class keeps coming back — so both arms are wired here. Both now: `LoadFailed` + retry on a failed first load, in place of the region the payload feeds; `InlineError` beside the data on a failed refresh (ent#253). The failure arm deliberately replaces only the `/page`-fed regions and leaves the chat list alone — one wrapper around the whole body would hide a list that is still perfectly good. Every arm gates on the VERDICT, never on `loading` (#2540/#1927). Two ent#523 guards pinned the band's literal `v-if="!loaded"`. The properties they protect — skeleton shaped like the row it precedes, gated on the verdict and never on `loading` — are unchanged, so they are re-pointed at the narrowed gate rather than dropped. ent#547 is why this was worth fixing rather than recording: the body is now mounted once per participant in a room, so N independent chances to fail silently — including for a participant off the viewer's roster, where `_require_roster` answers a uniform 404 by design. Frontend suite: 2257 passed (102 files); loading-gate ratchet green. Related to #2597 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CdxGmvuKuqaWJZ6pKUgaUJ
…not a branch inside it
Self-review finding on my own first cut, in two parts.
LAYOUT. The `LoadFailed` arm landed INSIDE `flex flex-wrap items-center`, where
its centred icon column becomes a flex item in a row of stat figures — and
`dense` is `py-6` against a band that is `py-2.5`, so a failure would roughly
triple the band's height and shift the whole conversation column (contract
principle 4). Its sibling, the failed-refresh `InlineError`, sits outside that
row; the two faces of one failure were rendering in two different languages, in
two different places.
Both faces are now `InlineError`, and the first-load arm is a sibling of the
whole stat row. That placement is load-bearing rather than cosmetic: the row's
`v-else` renders `stats`, which falls back to `{ total_executions: 0 }` — zeros
are a claim about performance where "no data" is a claim about nothing, so a
banner ABOVE a still-rendering row would have reproduced the exact "empty
agent" defect this issue is about, one component over.
TESTS. My guard named `LoadFailed`, so replacing the primitive turned it red
for no behavioural reason — the same mistake I had just called out in the
ent#534 guard. It now pins the property: an arm gated on `error && !loaded`
that offers a retry, and the failure arm ordered before the stat row. A "one
failure language" guard replaces the primitive name.
Consequently the two ent#523 guards go back untouched: `v-if="!loaded"` is
unchanged again, so editing them was collateral of a design since replaced.
Frontend suite: 2258 passed (102 files).
Related to #2597
dolho
left a comment
There was a problem hiding this comment.
/review — PR #2602 (self-review)
Branch: fix/2597-info-body-load-failure → dev · Files: 5
Scope: CLEAN, with one deliberate widening — the issue is written about PortalAgentDetails, and the fix also covers PortalAgentBand. Justification under [I1].
Critical
None.
Findings from this pass — fixed here
[F1] My own failure arm reproduced the bug it was fixing, one component over (Confidence: 9/10)
src/frontend/src/components/portal/PortalAgentBand.vue
The first cut put LoadFailed v-else-if="!loaded" inside <div class="flex flex-wrap items-center gap-x-6 gap-y-2">. Two things wrong, and the second is the real one:
-
Layout.
LoadFailedrenders a centred icon column atpy-6; the band ispy-2.5. As a flex item in a row of stat figures it reads wrong, and it roughly triples the band's height on failure, shifting the whole conversation column down — contract principle 4. Its own sibling, the failed-refreshInlineError, sits outside that row. One failure, two languages, two places. -
The arm had to be a sibling of the row, not a branch inside it. The row's
v-elserendersstats, which the composable falls back to as{ total_executions: 0, timeline: [] }. Zeros are a claim about performance where "no data" is a claim about nothing — so a failure banner sitting above a still-rendering stat row would have been the exact "empty agent" defect this issue is about, reproduced in the component I was adding the fix to.
Both faces are now InlineError, and the failure arm is a sibling of the whole row. Pinned by a guard that asserts the failure arm is ordered before the row rather than nested in it.
[F2] My guard named a primitive instead of a property (Confidence: 8/10)
portalAgentPageFailure.spec.js
expect(band).toMatch(/<LoadFailed[\s\S]{0,240}@retry="reload"/) — so replacing LoadFailed with InlineError turned it red for no behavioural reason. That is precisely the mistake I flagged in the ent#534 guard in #2601 and in the ent#523 guards below, made in a test I wrote in the same session. It now pins the property: an arm gated on error && !loaded that offers a retry, plus a "one failure language" guard.
Consequently the two ent#523 guards in portalAgentPageUx.spec.js go back untouched — v-if="!loaded" is unchanged again, so editing them was collateral of a design I have since replaced. Worth noting as a general lesson: I edited two other people's guards to accommodate a design that did not survive my own review pass.
Informational
[I1] The band fix is outside the issue's stated scope, deliberately (Confidence: 8/10)
#2597 is written about the Info body and cites the band as the surface that already follows the ent#253 rule. It follows half of it: InlineError v-if="error && loaded" covers a failed refresh, and a failed first load left three placeholders pulsing forever with no error and no retry.
There are exactly two consumers of usePortalAgentPage (grepped). Fixing one of two is the incomplete-fix class the repo keeps re-learning (/review 4.14), so both arms are wired. A reviewer who wants the band split out has a fair case; the counter-argument is that the two failures are the same rule and share a composable, and a follow-up issue for the other half of a two-line rule is overhead.
[I2] The panel header still degrades on a failed load — and I am not fixing it (Confidence: 8/10)
The issue lists "identity falls back to the slug, description blank" among the symptoms. I fixed the description (it is gated on header?.description, so it simply does not render) and the capabilities claim, and left the header. Checked rather than assumed:
healthLabelfalls back to'Status unknown'(PortalAgentDetails.vue:243) — honest;availabilityis computed fromprops.agent, the roster payload — a different fetch — withheader.value?.owneronly as an owner fallback, so it stays valid;- the label degrades to the slug via
agentDisplayName.
None of those is a false claim; the slug is a degraded label, not an assertion. The only thing that asserted something untrue was "hasn't published anything it can do yet", which is fixed. Stating this explicitly so the remaining degradation is a recorded decision rather than an oversight.
Clean (verified)
error/loaded/reloadwere already exposed by the composable — the whole fix is consumer-side, pinned by a guard so a future change there cannot silently make these guards test nothing.- The chat list is deliberately outside the failure arm, pinned by a test: it is a slice of the shell's thread list, and one wrapper around the body would hide a list that is still perfectly good.
- Every arm gates on the verdict, never on
loading(#2540/#1927);loadingGateRatchetgreen; raw-colour scan clean on the touched files; semantic status tokens only. - Frontend suite: 2258 passed (102 files).
Summary
Critical: 0 · Findings fixed in-pass: 2 · Informational: 2 · Scope: one stated widening.
Cannot self-approve; posting as a review comment. [F1] is the substantive one — it was a real defect in my own fix, caught by asking where the element actually renders rather than whether the test passed.
…the window selector
Re-review of my own previous fix, which over-corrected.
That commit moved the failure OUT of the stat row entirely, replacing the whole
row. The objection it was answering was real but specific to the PRIMITIVE:
`LoadFailed` renders a centred `py-6` column, which in a `py-2.5` band is both
shaped wrong for a row of stat figures and a layout shift of the conversation
column. It was not an objection to the PLACEMENT.
Replacing the row cost something I did not notice until re-reading where the
elements actually render: the window `<select>` is a sibling of the v-if chain,
not part of it, so it went with the numbers. Changing the window is a second
way out of a failure — `watch(timeWindow)` re-fetches — alongside the retry
button, and a failed 30d load could no longer be answered by trying 7d.
So the failure is now the FIRST ARM of the existing chain, inside the row.
`InlineError` is row-shaped, so it costs no layout; the selector renders in
every state; and it is still an arm rather than a banner, because the `v-else`
branch renders `stats`, which falls back to `{ total_executions: 0 }` — zeros
are a claim about performance where "no data" is a claim about nothing.
Tests: the ordering guard now pins the three-arm chain, plus a new guard that
the selector stays reachable while the failure shows — the regression this pass
caught. The two ent#523 guards are matched position-independently
(`v-(?:else-)?if="!loaded"`) so they keep asserting their own properties, the
skeleton's shape and never-gate-on-`loading`, instead of failing every time an
arm is added above them. I had already edited those two guards twice for this
one change; that was the signal they were pinned too tightly.
Frontend suite: 2259 passed (102 files).
Related to #2597
dolho
left a comment
There was a problem hiding this comment.
/review — re-review of the fix commits (PR #2602)
Scope: the two commits added since the first review, not the original diff. Verified: full frontend suite 2259 passed (102 files) at the new head.
Re-reviewing my own fix found a defect the fix itself introduced — the useful outcome of this pass.
Critical
None.
Finding — fixed in this pass
[F3] The previous fix over-corrected and removed a recovery affordance (Confidence: 9/10)
PortalAgentBand.vue
The last commit moved the failure out of the stat row and replaced the whole row with it. Re-reading where the elements actually render rather than whether the tests passed:
<div class="flex flex-wrap items-center …"> ← the row I was replacing
<template v-if="!loaded"> skeleton </template>
<template v-else> …stats… </template>
<div class="flex-1"></div>
<select v-model="timeWindow" …> ← a SIBLING of the chain, not part of it
</div>So replacing the row took the window <select> with the numbers. Changing the window is a second way out of a failure — watch(timeWindow, () => load()) re-fetches — alongside the retry button, so a failed 30d load could no longer be answered by trying 7d.
The objection that drove the previous commit was real but specific to the primitive: LoadFailed's centred py-6 column in a py-2.5 band is shaped wrong for a row of stat figures and a layout shift of the conversation column. It was never an objection to the placement. I generalised it one step too far.
The failure is now the first arm of the existing chain, inside the row: InlineError is row-shaped so it costs no layout, the selector renders in every state, and it is still an arm rather than a banner — the v-else branch renders stats, which falls back to { total_executions: 0 }, and zeros are a claim about performance where "no data" is a claim about nothing.
[F4] I had now edited two other people's guards twice for one change (Confidence: 8/10)
portalAgentPageUx.spec.js
The ent#523 guards pin v-if="!loaded". My first design made it !loaded && !error; my second made it v-else-if="!loaded"; each time I edited those two tests to match. Editing someone else's guard twice for one change is the signal that it is pinned too tightly, not that it keeps being wrong.
They now match v-(?:else-)?if="!loaded" — position-independent — so they keep asserting the two properties they exist for (the skeleton's shape; never gating on loading) and stop failing whenever an arm is added above them. Both negative assertions are untouched.
Informational
[I3] Three structural iterations on a two-line rule (Confidence: n/a)
Inside-the-row (LoadFailed) → sibling-of-the-row (InlineError) → arm-inside-the-row (InlineError). The middle step was wrong and the tests passed at every step, because at each step I had pinned the shape I had just written. The guard that finally caught it is the new one — "the selector stays reachable while the failure shows" — which asserts a user-visible consequence rather than a structure. Recorded because it is the transferable lesson from this PR, not because the end state is in doubt.
Clean (re-verified at the new head)
- Every state of the three-arm chain:
error && !loaded→ InlineError;!loaded→ skeleton; otherwise → stats, with the failed-refreshInlineErrorstill below and gated onloaded. No state renders zeros, and none renders both a skeleton and a failure. - The window selector renders in all three, pinned by a guard that also checks no
v-if/v-elseopens between the last arm and the<select>. LoadFailedis fully gone from the band — import removed, and the "one failure language" guard reads a comment-stripped view so the reference in the explanatory comment cannot make it vacuous.PortalAgentDetailsis unchanged by these two commits and keepsLoadFailed, which is right there: it sits in a verticalspace-y-6column and replaces a section body, exactly as the Reports section below it already does.- Suite 2259 passed;
loadingGateRatchetgreen.
Summary
Critical: 0 · Fixed in this pass: 2 · Informational: 1.
|
Resolve by running |
|
Resolve by merging |
|
merge-train: ejected — structural conflict with #2604. The compact-header change removed the |
|
merge-train (2026-09-09): not on this train — |
Structural conflict with #2604, per the merge-train note. Resolved by taking dev's structure and re-applying this PR's two additions into it: * ent#547 removed the `flex-1 … p-4 space-y-6` wrapper in `PortalAgentDetails.vue` that the `<InlineError>` refresh-failure banner was inserted into. It is now a direct child of the panel body at dev's indentation; the ent#253 rule it implements is unchanged. * The `LoadFailed` arm still comes FIRST in "What it can do", ahead of the empty arm — the empty arm is a positive claim a failed fetch supplies no evidence for, which is the whole defect. Two guards in `portalAgentPageFailure.spec.js` were pinning shapes dev has since changed: * `'reload: load'` — #2604 rewrote the alias to `const reload = () => load({ force: true })`. The consumers need the composable to RETURN something called `reload`, not for its definition to have a particular spelling, so the assertion now says that. * "keeps the time-window selector reachable while the failure shows" — ent#547 DELETED that selector (the window is fixed at 7 days). The test asserted a control that no longer exists. Its structural half survives — the failure is an ARM INSIDE the row, so anything else the row holds is a sibling of the chain — and it now also pins the selector's absence rather than silently passing on a missing element. Frontend suite: 107 files, 2397 tests, all passing.
cfccb6c to
533eb67
Compare
|
The #2604 conflict, resolved as you described. Took dev's structure and re-applied this PR's two additions into it:
Two guards were pinning shapes dev has since changed — you called the first, and re-running the suite found its twin:
Frontend suite on the merged branch: 107 files, 2397 tests, all passing. |
vybe
left a comment
There was a problem hiding this comment.
merge-train (2026-09-09): validated at lane B — /validate-pr + /review clean, touched unit tests and ratchets pass locally against the current dev tip. Below the train floor, so merged individually.
Summary
usePortalAgentPagehas always returnederror,loadedandreload. Both consumers ignored a different half of them, and the two failures are mirror images:PortalAgentDetails(before)PortalAgentBand(before)InlineError✓LoadFailed+ retryInlineErrorbeside the data ✓The reported half
PortalAgentDetailsdestructured{ header, capabilities }only, so a failed/pagefell through to the computed fallbacks: identity degraded to the slug, the description vanished, and "This agent hasn't published anything it can do yet" appeared — a positive claim about the agent on no evidence, indistinguishable from a real answer. "Your chats" kept working (a slice of the shell's thread list, not/page), which made the rest read as loaded rather than broken.The half the issue attributes to the band
The issue cites the band as the surface that already follows the ent#253 rule. It follows half of it:
InlineErroris gated onloaded, so a failed first load leaves three placeholders pulsing forever with no error and no retry. There are exactly two consumers of this fetch, and fixing one of two is how this class keeps coming back — so both arms are wired here.Scope of the failure arm
It replaces only the regions
/pagefeeds and deliberately leaves the chat list alone: one wrapper around the whole body would hide a list that is still perfectly good. Every arm gates on the verdict (loaded), never onloading(#2540/#1927).Tests
Two ent#523 guards pinned the band's literal
v-if="!loaded". The properties they protect — skeleton shaped like the row it precedes, gated on the verdict and never onloading— are unchanged, so they are re-pointed at the narrowed gate rather than dropped.New
portalAgentPageFailure.spec.jspins both consumers, both directions, and that the chat list stays out of it. Frontend suite: 2257 passed (102 files); loading-gate ratchet green.Why now
ent#547 widened it: the body is mounted once per participant in a room, so N independent chances to fail silently — including for a participant off the viewer's roster, where
_require_rosteranswers a uniform 404 by design.Fixes #2597
🤖 Generated with Claude Code
https://claude.ai/code/session_01CdxGmvuKuqaWJZ6pKUgaUJ