mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
6 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2902b8c773
|
fix(web): harden build version detection | ||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|