mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-08 22:21:45 +00:00
## 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>
292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
import { useState, useRef } from "react";
|
|
import {
|
|
Listbox,
|
|
ListboxButton,
|
|
ListboxOption,
|
|
ListboxOptions,
|
|
} from "@headlessui/react";
|
|
import { ArrowUpIcon } from "@heroicons/react/24/solid";
|
|
import {
|
|
ChevronUpDownIcon,
|
|
FolderIcon,
|
|
} from "@heroicons/react/16/solid";
|
|
import {
|
|
BugAntIcon,
|
|
CodeBracketIcon,
|
|
MagnifyingGlassIcon,
|
|
XMarkIcon,
|
|
} from "@heroicons/react/24/outline";
|
|
|
|
export const handle = { hideHeader: true, wide: true };
|
|
|
|
export function meta({}: any) {
|
|
return [{ title: "Start — Fabro" }];
|
|
}
|
|
|
|
const projects = [
|
|
{ id: "fabro-web", name: "fabro-web" },
|
|
{ id: "fabro-workflows", name: "fabro-workflows" },
|
|
{ id: "fabro-cli", name: "fabro-cli" },
|
|
];
|
|
|
|
const branches = [
|
|
{ id: "main", name: "main" },
|
|
{ id: "develop", name: "develop" },
|
|
{ id: "feature/start-page", name: "feature/start-page" },
|
|
];
|
|
|
|
function BranchIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg viewBox="0 0 16 16" fill="currentColor" className={className}>
|
|
<path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.5 2.5 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export default function Start() {
|
|
const [prompt, setPrompt] = useState("");
|
|
const [project, setProject] = useState(projects[0]);
|
|
const [branch, setBranch] = useState(branches[0]);
|
|
const [openCategory, setOpenCategory] = useState<string | null>(null);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
function autoResize() {
|
|
const el = textareaRef.current;
|
|
if (!el) return;
|
|
el.style.height = "auto";
|
|
el.style.height = Math.min(el.scrollHeight, 280) + "px";
|
|
}
|
|
|
|
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
if (prompt.trim()) handleSubmit();
|
|
}
|
|
}
|
|
|
|
function handleSubmit() {
|
|
if (!prompt.trim()) return;
|
|
// TODO: wire up submission
|
|
}
|
|
|
|
return (
|
|
<div className="flex -mx-4 sm:-mx-6 lg:-mx-8 -my-6">
|
|
<div className="flex-1 flex flex-col items-center pt-[12vh] px-4">
|
|
<div className="w-full max-w-2xl">
|
|
<h1 className="flex items-center justify-center gap-3 text-[2rem] font-medium tracking-tight text-fg-2 text-center mb-8">
|
|
<img src="/images/logo.svg" alt="" className="size-9" />
|
|
What do you want to build?
|
|
</h1>
|
|
|
|
<div className="relative group">
|
|
<div className="absolute -inset-px rounded-xl bg-gradient-to-b from-teal-500/30 to-mint/20 opacity-0 blur-sm transition-opacity duration-300 group-focus-within:opacity-100" />
|
|
|
|
<div className="relative rounded-xl bg-panel border border-line-strong group-focus-within:border-focus transition-colors duration-300">
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={prompt}
|
|
onChange={(e) => {
|
|
setPrompt(e.target.value);
|
|
autoResize();
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
aria-label="Workflow prompt"
|
|
placeholder="Describe a workflow, pipeline, or automation..."
|
|
autoFocus
|
|
rows={3}
|
|
className="w-full resize-none bg-transparent px-5 pt-4 pb-14 text-[15px] leading-relaxed text-fg-2 placeholder:text-fg-muted focus:outline-none"
|
|
/>
|
|
|
|
<div className="absolute bottom-3 inset-x-3 flex items-center justify-between">
|
|
<div className="flex items-center gap-1.5">
|
|
<Picker
|
|
value={project}
|
|
onChange={setProject}
|
|
options={projects}
|
|
icon={<FolderIcon className="size-3.5 text-fg-muted" />}
|
|
/>
|
|
<Picker
|
|
value={branch}
|
|
onChange={setBranch}
|
|
options={branches}
|
|
icon={<BranchIcon className="size-3.5 text-fg-muted" />}
|
|
/>
|
|
</div>
|
|
|
|
<div className="ml-auto flex items-center gap-3">
|
|
<span className="text-xs text-fg-muted select-none">
|
|
<kbd className="font-mono">Enter</kbd> to submit
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleSubmit}
|
|
disabled={!prompt.trim()}
|
|
aria-label="Submit prompt"
|
|
className="flex items-center justify-center size-8 rounded-lg bg-teal-500 text-on-primary transition-all duration-200 hover:bg-teal-300 disabled:opacity-30 disabled:hover:bg-teal-500 disabled:cursor-default"
|
|
>
|
|
<ArrowUpIcon className="size-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative mt-5">
|
|
<div className="flex items-center justify-center gap-2">
|
|
{categories.map((cat) => (
|
|
<button
|
|
type="button"
|
|
key={cat.label}
|
|
onClick={() => setOpenCategory(openCategory === cat.label ? null : cat.label)}
|
|
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm transition-colors ${
|
|
openCategory === cat.label
|
|
? "border-teal-500/30 bg-teal-500/10 text-teal-300"
|
|
: "border-line bg-panel/50 text-fg-3 hover:bg-panel hover:border-line-strong"
|
|
}`}
|
|
>
|
|
<cat.icon className="size-4" />
|
|
{cat.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{openCategory && (
|
|
<div className="absolute inset-x-0 top-0 z-10">
|
|
<CategoryPanel
|
|
category={categories.find((c) => c.label === openCategory)!}
|
|
onClose={() => setOpenCategory(null)}
|
|
onSelect={(p) => {
|
|
setPrompt(p);
|
|
setOpenCategory(null);
|
|
textareaRef.current?.focus();
|
|
setTimeout(() => {
|
|
const el = textareaRef.current;
|
|
if (!el) return;
|
|
el.style.height = "auto";
|
|
el.style.height = Math.min(el.scrollHeight, 280) + "px";
|
|
}, 0);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface Category {
|
|
label: string;
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
items: { title: string; prompt: string }[];
|
|
}
|
|
|
|
const categories: Category[] = [
|
|
{
|
|
label: "Build",
|
|
icon: CodeBracketIcon,
|
|
items: [
|
|
{ title: "Implement a new feature", prompt: "Implement a new feature that adds user authentication with OAuth2, including login, logout, and session management." },
|
|
{ title: "Create an API endpoint", prompt: "Create a new REST API endpoint with request validation, error handling, and proper HTTP status codes." },
|
|
{ title: "Set up a CI/CD pipeline", prompt: "Set up a CI/CD pipeline with build, test, lint, and deploy stages for the main branch." },
|
|
{ title: "Add database migrations", prompt: "Add database migrations to create the new tables and indexes needed for the upcoming feature." },
|
|
],
|
|
},
|
|
{
|
|
label: "Review",
|
|
icon: MagnifyingGlassIcon,
|
|
items: [
|
|
{ title: "Review a pull request", prompt: "Review the latest pull request for bugs, security vulnerabilities, and code style issues. Summarize findings and suggest fixes." },
|
|
{ title: "Audit dependencies", prompt: "Audit all project dependencies for known vulnerabilities, outdated versions, and unused packages." },
|
|
{ title: "Analyze test coverage", prompt: "Analyze the current test coverage, identify untested code paths, and recommend which areas need tests most." },
|
|
{ title: "Check for security issues", prompt: "Scan the codebase for common security vulnerabilities including injection, XSS, and authentication flaws." },
|
|
],
|
|
},
|
|
{
|
|
label: "Fix",
|
|
icon: BugAntIcon,
|
|
items: [
|
|
{ title: "Debug a failing test", prompt: "Debug the failing test suite, identify the root cause of each failure, and apply fixes." },
|
|
{ title: "Fix a production bug", prompt: "Investigate and fix the reported production bug, including root cause analysis and a regression test." },
|
|
{ title: "Resolve merge conflicts", prompt: "Resolve the merge conflicts in the current branch, preserving the intended changes from both sides." },
|
|
{ title: "Fix type errors", prompt: "Fix all TypeScript type errors in the project, ensuring strict type safety without using any type assertions." },
|
|
],
|
|
},
|
|
];
|
|
|
|
function CategoryPanel({
|
|
category,
|
|
onClose,
|
|
onSelect,
|
|
}: {
|
|
category: Category;
|
|
onClose: () => void;
|
|
onSelect: (prompt: string) => void;
|
|
}) {
|
|
return (
|
|
<div className="rounded-xl border border-line-strong bg-panel overflow-hidden">
|
|
<div className="flex items-center gap-2 px-4 py-3 border-b border-line">
|
|
<category.icon className="size-4 text-teal-500" />
|
|
<span className="text-sm font-medium text-fg-2">{category.label}</span>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="Close category menu"
|
|
className="ml-auto flex items-center justify-center size-6 rounded-md text-fg-muted hover:text-fg-3 hover:bg-overlay transition-colors"
|
|
>
|
|
<XMarkIcon className="size-4" />
|
|
</button>
|
|
</div>
|
|
<ul>
|
|
{category.items.map((item, i) => (
|
|
<li key={item.title} className={i > 0 ? "border-t border-line" : ""}>
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelect(item.prompt)}
|
|
className="w-full px-4 py-3 text-left text-sm text-fg-3 transition-colors hover:bg-overlay hover:text-fg-2"
|
|
>
|
|
{item.title}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Picker<T extends { id: string; name: string }>({
|
|
value,
|
|
onChange,
|
|
options,
|
|
icon,
|
|
}: {
|
|
value: T;
|
|
onChange: (v: T) => void;
|
|
options: T[];
|
|
icon: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<Listbox value={value} onChange={onChange}>
|
|
<div className="relative">
|
|
<ListboxButton className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-fg-3 bg-page/60 border border-line hover:border-line-strong hover:bg-page/80 transition-colors">
|
|
{icon}
|
|
<span className="max-w-[120px] truncate">{value.name}</span>
|
|
<ChevronUpDownIcon className="size-3.5 text-fg-muted" />
|
|
</ListboxButton>
|
|
|
|
<ListboxOptions anchor="top start" className="z-20 w-56 rounded-lg bg-panel border border-line-strong py-1 shadow-xl shadow-black/30 focus:outline-none [--anchor-gap:4px]">
|
|
{options.map((option) => (
|
|
<ListboxOption
|
|
key={option.id}
|
|
value={option}
|
|
className="flex items-center gap-2 px-3 py-1.5 text-xs text-fg-3 data-focus:bg-overlay data-selected:text-teal-300"
|
|
>
|
|
{option.name}
|
|
</ListboxOption>
|
|
))}
|
|
</ListboxOptions>
|
|
</div>
|
|
</Listbox>
|
|
);
|
|
}
|