Commit graph

6 commits

Author SHA1 Message Date
Bryan Helmkamp
2902b8c773
fix(web): harden build version detection 2026-07-25 15:15:25 -04:00
Bryan Helmkamp
695a981f42
feat(web): tell open tabs when a new build ships
A tab left open across a deploy keeps running the previous build's
JavaScript indefinitely. index.html is fetched only on a full page load,
all later navigation is client-side, and hashed bundles are served
`immutable`, so nothing reveals that the code is stale. This produced a
false-positive bug report where two correctly-deployed fixes appeared to
be missing.

Publishes a build id and offers a reload when the running document falls
behind. The toast never reloads on its own; the only automatic reload is
recovery from a chunk that no longer exists.

Build id derivation
-------------------
The obvious approach — hash the emitted asset filenames, which already
embed content hashes — does not work: Bun's minified identifier naming is
not deterministic. Building an unchanged tree twice produces byte-different
output roughly one run in three (same length, ~100k differing bytes, all of
it mangled names). Output hashes therefore move with no source change,
which would fire the toast on redeploys of identical code and train people
to ignore it.

The id is instead derived from the bundle's source inputs, so it changes if
and only if something we control changed. Verified stable across eight
consecutive builds while the entry hash flipped between both variants.

This non-determinism also means two builds of the same commit embed
different bytes into the server binary, which is worth addressing
separately for reproducible builds.

Detection
---------
SWR with `refreshInterval` + `revalidateOnFocus`, per the repo's React
effects policy. SWR does not poll while the document is hidden, so
background tabs stay quiet without extra gating. Unknown state on either
side — missing meta tag, failed fetch, 503 during a dev rebuild — never
produces a prompt.

Stylesheet hashing
------------------
Tailwind's output was stable-named and therefore served `no-cache`, letting
a tab revalidate into new CSS while running old JS. Tailwind purges unused
classes per build, so classes the old bundle still emits could silently
lose their styles. It is now content-hashed and moves with the build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 14:45:26 -04:00
André Mazoni
c5dd5772d0
Keep run graph zoom/pan when switching tabs (#561)
Switching from a run's Overview tab to another tab and back reset the
graph zoom and position to the default. Now it holds.

## Why

The viewport (pan and zoom) lived in `RunOverview` component state.
Overview and Stages are sibling routes under `runs/:id`, so switching
tabs unmounts Overview and drops that state.

## Fix

`apps/fabro-web/app/routes/run-overview.tsx`: cache the viewport per run
outside the component so it survives the remount, and reset it when the
run id changes, since the route instance is reused when only the id
changes.

Added two tests: viewport restores on remount for the same run, and does
not carry across runs.

Does not persist across a full page reload (in-memory only).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
2026-07-08 10:50:02 -04:00
André Mazoni
df4fee4dff
feat(web): trackpad pan + ⌘-scroll zoom on the run graph (#555)
## What

On **Runs → Overview**, the workflow graph now supports the standard
Figma/Excalidraw canvas interactions:

- **Two-finger scroll → pan**
- **⌘/Ctrl + scroll → zoom**, anchored under the cursor (mac trackpad
pinch works too — the browser delivers it as `ctrl+wheel`)

The graph already had drag-to-pan, stepped zoom (toolbar +/−), and
fit-to-window. This adds the missing wheel/trackpad input on top of that
existing transform state.



https://github.com/user-attachments/assets/15eac98b-2603-44c9-b438-7ee27034ccd7


## How

- **`app/lib/graph-viewport.ts`** (new) — pure, framework-free zoom
math: `zoomAtPoint` keeps the point under the cursor fixed while
scaling; `clampZoom` + zoom constants. Zoom becomes a continuous float
(was a discrete step index) so ⌘-scroll is smooth instead of jumping
between steps. Unit-tested (`graph-viewport.test.ts`), including the
cursor-anchor invariant.
- **`useElementEvent` in `hooks/effects.ts`** (new) — element-scoped,
non-passive listener, a sibling to the existing
`useWindowEvent`/`useDocumentEvent`. Non-passive is required so the
handler can `preventDefault()` the browser's own ⌘-zoom; a JSX `onWheel`
can't.
- **`routes/run-overview.tsx`** — coalesces zoom+pan into one `view`
state (atomic cursor-anchored updates), adds the wheel handler (plain
scroll → pan, ⌘/Ctrl → zoom), and `touch-none overscroll-contain` so a
horizontal swipe can't trigger browser back-nav.
- **`components/graph-toolbar.tsx`** — presentational continuous
interface; +/− buttons reuse `zoomAtPoint` (center-anchored). Deletes
the now-dead `graph-toolbar-constants.ts`.

## Testing

- `bun run typecheck` clean; `bun test` green (incl. 4 new viewport
tests).
- Verified live against a real 10-node run graph via Chrome DevTools:
two-finger pan tracks the scroll delta; ⌘+wheel zoom is cursor-anchored
(confirmed even with the cursor over a node); toolbar +/− step ×1.25 and
clamp/disable at 200%; fit-to-window sets a continuous scale; node
click/hover unaffected.

## Non-goals

- **Playground canvas** (`components/playground/canvas`) shares the same
hand-rolled pan/zoom pattern and also lacks wheel support — deliberately
out of scope; `graph-viewport.ts` is the seam to adopt it later.
- **No persistence** — zoom/pan stays ephemeral per visit, as it was
before.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:15:52 -04:00
fabro-sh-0530[bot]
b196a97ac4
Introduce approved effect hooks and migrate direct useEffect calls (#425)
## 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>
2026-05-27 10:37:29 -04:00
fabro-sh-0530[bot]
79f89165f6
Wire end-to-end steering for running agents (#209)
## Summary
This makes the advertised mid-run steering path real: users can send
append or interrupt steering messages through the API, CLI, and web UI,
and the worker delivers them to live API-mode agent sessions or buffers
them for the next session. The change adds the control protocol, session
interrupt machinery, workflow hub, server route/OpenAPI/client updates,
and UI feedback needed for the whole path.

### Plan Summary
- Add `SteerKind`/`run.steer` wire protocol and `POST /runs/{id}/steer`
- Deliver steers through subprocess JSONL or the in-process
`SteeringHub`
- Support append and interrupt behavior in agent sessions, with bounded
buffering and events
- Expose steering in the CLI/web UI and surface SSE toasts

## Flow

```mermaid
flowchart TB
  UI["CLI / Web UI"] --> API["POST /runs/{id}/steer"]
  API -->|"subprocess transport"| Control["Worker control JSONL"]
  API -->|"in-process transport"| Hub["SteeringHub"]
  Control --> Hub
  Hub -->|"active API sessions"| Session["SessionControlHandle"]
  Hub -->|"no active session"| Pending["Pending buffer"]
  Pending -->|"first future API session"| Session
  Session --> Agent["Session round loop"]
  Agent --> Events["RunEvent stream"]
  Events --> UI
```

## What changed and why

- Agent sessions now expose a lightweight `SessionControlHandle`, drain
steering at the top of each round, and use a replaceable round
cancellation token for interrupts. LLM waits are cancelled promptly,
while tool execution observes cancellation cooperatively so every
committed `tool_use` still gets a matching `tool_result`.
- `SteeringHub` owns active API session registration, broadcast
delivery, pending buffering, FIFO queue caps, and steering
lifecycle/drop events. A completion coordinator closes the
final-response race without introducing a workflow dependency into the
agent crate.
- The server route replaces the 501 stub, validates run state and
best-effort CLI-only steerability, and forwards through either
subprocess control JSONL or the in-process hub. OpenAPI and generated
clients now include the request type.
- The CLI and web UI can send append or interrupt steers. Run detail and
board views open the new composer, and shared SSE subscriptions now
support per-subscriber event callbacks so invalidation and steering
toasts can coexist on one EventSource.

## Review notes

- Steering actors stay on top-level `RunEvent.actor`; event props only
carry steering kind/drop metadata.
- Buffered steers replay as append messages to the first API session
that registers after an empty-active period. Per-stage targeting remains
out of scope.
- CLI-mode agent stages are still not steerable; the server returns a
best-effort 409 when all active agent stages are CLI-mode, while the
worker hub remains the authoritative safety net.
- No persistence or schema migration is required; active and pending
steering state is in memory.
- New tests focus on protocol round-trips, hub buffering/bounds, session
steering-loop behavior, SSE fanout, and basic server rejection paths.

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:34:16 -04:00