The interview question panel could take half the viewport, and the page
reserved a fixed 18rem beneath it regardless of how tall it actually was,
so a long question covered the stage rows it was asking about.
Add a shared `RunDockShell` for the two controls docked at the bottom of
the run detail route. It is three zones: a header that is always visible
and doubles as the collapsed bar, a body that scrolls, and actions that
stay pinned so the controls needed to answer or send never scroll out of
reach.
The interview dock drops the question-type subtitle the answer buttons
already state, turns the 160px context box into a closed disclosure with
a first-line preview, drops the "or" divider row, and reveals the
keyboard hint on focus inside the composer row instead of standing below
it. Options stack into a list once a label is too long to sit in a pill.
For the sample question this is 506px down to 325px, or 43px collapsed.
The steering dock gains the same header. `Interrupt` moves into it,
because it acts on the run rather than on the message being composed, and
the waiting notice folds into the header status instead of adding a row.
Both docks now share one composer.
Clearance is measured from the rendered dock rather than assumed. The
former constants remain as the pre-measurement first frame.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`active_time_ms` was only ever computed from terminal stage events, so a
stage still running contributed zero to the run rollup. A run parked in one
long agent stage reported 2m 8s of active time against 16m 53s of wall
clock — the two finished stages — while the running stage had been doing
continuous inference and tool work for over 14 minutes.
`live_run_timing` summed `filter_map(|stage| stage.timing)`, and
`stage.timing` is only written at finalization. Wall time ticked live off
`start_time`; active time did not tick at all.
Stage projections now accumulate brackets from the event log:
- Closing an inference bracket folds its span into `live_inference_ms`
instead of discarding it, including across retries, matching the
in-process stopwatch.
- Tool calls open a batch on the first outstanding call and close it when
the last one drains, so tools running concurrently within a turn count
once — the same span `execute_tool_calls` is bracketed by. Summing
per-call durations would over-count parallel tool use. Subagent tool
events are excluded; they run inside the root call's span already.
- `StageProjection::live_timing(now)` composes accumulators with any open
bracket, per handler: agent stages use the brackets, prompt and command
stages count elapsed time as inference and tool respectively, and
handlers that wait on a human, timer, condition, or child branches
report zero.
Active is clamped to wall per stage. A worker killed mid-turn leaves its
bracket open forever, and without the clamp it would tick up unbounded.
The clamp does not need to detect the dead worker: a stage cannot have been
active longer than it has existed. `watchdog.timeout` remains the authority
on whether a run is stuck. The clamp is deliberately not applied at run
level, where concurrent branches can legitimately sum past run wall time.
Timing is derived from events rather than emitted by the worker, so this
needs no event-schema change and applies to runs already stored.
`StageProjection.timing` keeps its terminal-only meaning, and the
authoritative breakdown still replaces the live estimate at terminal
events.
The billing endpoint had the same hole behind its `wall_only` fallback:
running stages reported zero inference/tool/active. Not visible in the
product, which renders only `wall_time_ms`, but wrong for any other
consumer of `GET /runs/{id}/billing`.
Parallel branch stages lose their breakdown permanently, even after
completion, because `parallel.branch.completed` carries only `duration_ms`.
That is a separate data-loss bug, tracked in #644.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
## Summary
Fixes sandbox state reporting by separating a requested sandbox plan
from an initialized sandbox instance. Runs now project sandbox lifecycle
as `planned`, `initializing`, `ready`, or `failed`, and live sandbox
operations only proceed once a real instance exists.
## Changes
- Introduces `RunSandboxPlan`, `RunSandboxInstance`, and
lifecycle-backed `RunSandbox` domain types, with serde validation that
prevents `ready` sandboxes without an instance.
- Updates store projection behavior so sandbox events transition through
planned, initializing, ready, and failed states while preserving
requested provider/image/snapshot separately from runtime metadata.
- Tightens server sandbox handlers so
details/files/services/terminal/VNC helpers require an initialized
instance and return a clear 404 when the sandbox was never created.
- Updates the OpenAPI contract and regenerated clients so `Run.sandbox`
exposes lifecycle state while `SandboxDetails.sandbox` contains only
initialized instance metadata.
- Updates the web UI to render lifecycle state directly from run
summaries, hide the Sandbox tab for pure planned sandboxes, and disable
sandbox controls until the instance is ready.
- Cleans up duplicated lifecycle display/type logic and duplicate
server-side sandbox instance loading found during review.
| Lifecycle state | Meaning | Live controls |
| --- | --- | --- |
| `planned` | Sandbox was requested but no provider instance exists |
Hidden/disabled |
| `initializing` | Provider setup has started | State view only |
| `ready` | Runtime instance exists | Enabled |
| `failed` | Provider setup failed with error details | State view only
|
## Testing
- `cargo check --workspace`
- `cargo +nightly-2026-04-14 fmt --check --all`
- `git diff --check`
- `cd apps/fabro-web && bun run typecheck`
- `cd apps/fabro-web && bun test app/routes/run-detail.test.ts
app/routes/run-sandbox.test.tsx
app/components/run-summary-panel.test.tsx`
- `cargo nextest run -p fabro-types --test sandbox_model_serde`
- `cargo nextest run -p fabro-store
run_created_projects_planned_sandbox_lifecycle
sandbox_lifecycle_events_update_projected_sandbox_state
run_failed_before_sandbox_events_leaves_sandbox_planned`
- `cargo nextest run -p fabro-server
planned_sandbox_returns_404_from_details_endpoint
planned_sandbox_rejects_live_operations
failed_sandbox_rejects_live_operations
local_sandbox_returns_provider_neutral_details`
- `cargo nextest run -p fabro-api --test run_sandbox_round_trip`
- `cargo nextest run -p fabro-api --test sandbox_details_round_trip`
---
[](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 via [Codex](https://openai.com/codex)
## Summary
Implements the React Effects Policy by creating the approved hook
surface in `hooks/effects.ts` and migrating a broad set of direct
`useEffect` calls across the codebase to either purpose-named hooks or
non-effect patterns.
### Plan Summary
- Add `hooks/effects.ts` exporting `useMountEffect`, `useInterval`,
`useTimeout`, `useDebouncedValue`, `useWindowEvent`, `useDocumentEvent`,
`useDocumentTitle`, `useMediaQuery`, `useLocationHash`, and
`useResizeObserver`
- Extract large imperative effects into purpose-named hooks:
`useTerminalSession`, `useFloatingTooltipMeasurements`,
`useAnnotatedRunGraphSvg`, `useInstallEffects`, and others
- Move install session fetch from a component effect into a SWR query
(`install-query.ts`)
- Replace `useEffect` + `useState` state-derivation patterns with
render-time computation or ref callbacks
- Replace `AskFabroLayoutProvider`/`useAskFabroLayout` context with a
prop callback
## What changed and why
**`hooks/effects.ts`** — the new approved primitive surface. All
internal `useEffect` calls here are intentional; the hooks expose the
*external system* they manage rather than leaking `useEffect` to
component code. `useMediaQuery` and `useLocationHash` use
`useSyncExternalStore` instead of effect + state.
**`useTerminalSession`** — the largest extraction. The 130-line
xterm/WebSocket/ResizeObserver setup block moves from
`terminal-view.tsx` into its own hook, which now owns the `terminalRef`,
`fitRef`, and `socketRef` that previously cluttered the component.
`TerminalConnectionError` and `ConnectionStatus` types are exported from
the hook.
**`useFloatingTooltipMeasurements`** — extracts the `useLayoutEffect` +
ResizeObserver + window resize listener out of `FloatingTooltip`. The
`FloatingTooltipSize` type moves with it so consumers don't need to
import from the component.
**`useInstallSessionQuery` + `useInstallEffects`** — the install session
fetch moves from a component effect to SWR (`install-query.ts`). The
three remaining install effects (token URL scrubbing, GitHub error URL
scrubbing, health-poll restart) move into
`hooks/use-install-effects.ts`. The root-redirect effect is replaced
with a render-time `<Navigate>` gate. The `SessionState` discriminant
now carries `token` so stale query results can be discarded without an
effect chain.
**`SelectionCheckbox`** — `useEffect` setting `input.indeterminate` is
replaced with a ref callback, which runs synchronously after the node is
attached and avoids a stale-frame flash.
**`event-debug.tsx`** — the manual `window.addEventListener("keydown",
...)` pattern is replaced with `useWindowEvent`, removing the
`react-doctor-disable` suppression comments.
**`run-waterfall.tsx`** — the local `useTickingNow` is deleted;
`RunWaterfall` now calls the shared `useTickingNow` from `lib/time` with
the new `active` parameter signature.
**`toast.test.tsx`** — `useEffect(() => onReady?.(api), ...)` in the
test helper is replaced with a direct call during render, which is valid
because `onReady` has no side effects that React cares about.
**`AskFabroSidebar`** — `setIsResizing` from the layout context is
replaced with an `onResizeActiveChange` prop, removing the
`useAskFabroLayout` call and the hidden context coupling from the
sidebar.
### Fabro Details
<details>
<summary>Ran 3 stages in 114m 5s for $95.71</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| work | 103m 3s | $80.42 | 0 |
| audit | 10m 19s | $15.29 | 0 |
| **Total** | **114m 5s** | **$95.71** | **0** |
</details>
<details>
<summary>Ran <code>Goal.fabro</code> (4 nodes and 5 edges)</summary>
```dot
digraph Goal {
graph [
goal="Complete the user-provided goal",
rankdir=LR,
max_node_visits=30
]
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
work [
label="Work",
thread_id="goal",
fidelity="full",
max_visits=12,
model="gpt-55",
reasoning_effort="xhigh",
prompt="@prompts/continue.md"
]
audit [
label="Completion Audit",
thread_id="goal",
fidelity="full",
goal_gate=true,
retry_target="work",
output_schema="routing",
output_retries=2,
max_visits=12,
model="gpt-55",
reasoning_effort="xhigh",
prompt="@prompts/audit.md"
]
start -> work -> audit
audit -> exit [label="Done", condition="outcome=succeeded"]
audit -> work [label="Continue", condition="outcome=failed || preferred_label=Continue"]
audit -> work [label="No clear verdict"]
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
## Summary
Improves the web UI's React Doctor audit score by separating reusable
helpers from React component modules, tightening effect/state ownership,
and extracting real component boundaries in the install wizard, stage
activity view, run-files diff browser, RunDetail route, and Runs
workspace. The branch removes the previously deferred RunDetail and Runs
giant-component diagnostics without changing RunDetail UX, route
contracts, action ordering, or Runs workspace behavior.
| Metric | Main baseline | Initial PR | Current PR |
|--------|---------------|------------|------------|
| React Doctor score | 63 | 71 | 99 |
| React Doctor errors | 123 | 0 | 0 |
| React Doctor warnings | 241 | 163 | 3 |
| React Doctor diagnostics | 364 | 163 | 3 |
## Changes
- Moves exported helper logic out of component files so Fast
Refresh/component-export rules no longer dominate the audit.
- Adds a targeted React Doctor config exception for React Router route
modules, where non-component exports like route metadata are
intentional.
- Refactors low-risk state/effect patterns: keyed interview question
state, reducer-backed editable run title state, event-owned preview
opening, route-keyed insights editor initialization, refresh timer
ownership, and selection/derived list cleanup.
- Reworks `InstallApp` around an install reducer, a controller hook for
install lifecycle state, and focused wizard step components for LLM,
server, object-store, sandbox, and GitHub setup.
- Moves `RunStages` selected-stage activity into a keyed boundary for
panel/debug detail state while preserving stage activity filters across
navigation.
- Extracts the `RunFiles` loaded diff-browser view from route/query
coordination so the route owns data/URL state and the loaded view owns
rendering.
- Splits `RunDetail` into route-local header, actions, tab shell, docked
controls, model, and lifecycle-toast modules; the actions menu now uses
grouped descriptors instead of a large boolean/callback prop matrix.
- Extracts Runs workspace preference ownership into
`useRunsWorkspacePreferences` and moves toolbar rendering into
`RunsToolbar`, leaving the route focused on data, DnD state, filtering,
and view selection.
- Guards `InsightsEditor` query execution with a latest-run id and
timeout cleanup so stale or unmounted mock query runs cannot overwrite
newer results.
- Adds regression coverage for archived-run deletion from RunDetail and
stale-result handling in InsightsEditor.
- Improves semantic/accessibility coverage with labeled controls, native
meter/section semantics, decorative status dots, and clearer unavailable
copy.
- Removes dead UI code and applies local suppressions only where the
rule is a documented false positive or an intentional imperative
integration boundary.
## Remaining React Doctor warnings
Current score is 99 with 0 errors and 3 warnings. The remaining warnings
are intentionally left for separate judgment rather than mechanical
churn:
- `prefer-useReducer` (3): `AutomationsNew`, `InsightsEditor`, and
`CreateSecretForm` need reducers only if they encode real coupled
transitions, not simple field setters.
## Verification
- `cd apps/fabro-web && bun test app/routes/run-detail.test.ts` -> `22
pass`, `0 fail`
- `cd apps/fabro-web && bun test app/routes/insights-editor.test.tsx
app/routes/runs.preferences.test.tsx` -> `7 pass`, `0 fail`
- `cd apps/fabro-web && bun run typecheck`
- `cd apps/fabro-web && bun test --isolate` -> `490 pass`, `0 fail`
- `cd apps/fabro-web && bunx react-doctor@latest --full --json >
/tmp/fabro-react-doctor-runs-insights.json` -> score `99`, `0` errors,
`3` warnings
- Earlier branch verification also included `cd apps/fabro-web && bun
run build`
- `git diff --check`
---
[](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 (context not reported, default reasoning) via
[Codex](https://openai.com/codex)