mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
## Summary
Ports the validated `/chats/new` and `/chats/:id` chat surface from
`docs/superpowers/prototypes/2026-05-16-chats-new/` into
`apps/fabro-web`. Client-side scripted prototype mounted inside the
existing `AppShell`; replaces `/start` as the planned new "kick off
agent work" entry point (but does not delete `/start` in this phase).
- New routes: `/chats/new` (empty-state composer) and `/chats/:chatId`
(active conversation with assistant-ui's `<Thread>`, scripted streaming
replies, markdown + tool-call rendering).
- Drives `@assistant-ui/react` + `@assistant-ui/react-ui` via
`useLocalRuntime` and a custom `ChatModelAdapter` that cycles a 6-entry
scripted reply bank.
- Tailwind v4 cascade fix: assistant-ui CSS is now imported via `@layer
assistant-ui` so v4 utilities cascade above the package's unlayered
scoped preflight. Includes a discovered Bun-specific tweak — see Notable
Deviations below.
- StrictMode-safe first-message handoff: store seeds the user message
into `seedMessages` with a `pendingResponse: true` flag, and
`chats-detail` triggers a single `runtime.thread.startRun({ parentId:
null })` then consumes the flag. Avoids the prototype's
autorespond-lost-stream race under React 19 StrictMode.
The Ask-Fabro right sidebar (also in the prototype) is **out of scope**
for this PR.
Companion spec:
[`docs/superpowers/specs/2026-05-16-chats-new-prototype-design.md`](../tree/chats-new-port/docs/superpowers/specs/2026-05-16-chats-new-prototype-design.md)
Implementation plan:
[`docs/superpowers/plans/2026-05-16-chats-new-fabro-web-port.md`](../tree/chats-new-port/docs/superpowers/plans/2026-05-16-chats-new-fabro-web-port.md)
## Screenshots
Captured from a local debug `fabro server` running this branch's binary,
signed in via GitHub.
### `/chats/new` (empty state)

### `/chats/:chatId` (active conversation)

## Files
**New** (under `apps/fabro-web/`):
- `app/lib/chats-types.ts` — `Chat` wrapper + `ChatContentPart`
discriminated union over the API client's `CompletionContentPart`
- `app/lib/chats-script.ts` — 6-entry scripted reply bank
(`CompletionMessage[]`)
- `app/lib/chats-store.tsx` — Context + `useReducer` for chat metadata,
`pendingResponse` flag, scriptIndex
- `app/lib/chats-runtime.ts` — `createScriptedAdapter` +
`toThreadMessages` boundary converter
- `app/lib/test-utils.tsx` — minimal `renderHook` shim (lifts the
duplicated `IS_REACT_ACT_ENVIRONMENT` + dep-warning silencing pattern
out of `install-app.test.tsx`)
-
`app/components/chats/{tool-fallback,composer-chips,custom-composer}.tsx`
- `app/routes/{chats-layout,chats-new,chats-detail}.tsx`
- Tests: `chats-store.test.tsx` (5), `chats-runtime.test.ts` (4),
`chats-router.test.tsx` (3)
**Modified:**
- `package.json` — adds `@assistant-ui/{react,react-ui,react-markdown}`
(pinned exactly to versions verified in the prototype)
- `app/app.css` — `@layer` declaration + assistant-ui CSS imports into
`layer(assistant-ui)` + `.fabro-chat` `--aui-*` variable overrides
mapping to the Fabro palette
- `app/root.tsx` — removed `import "./app.css"` (see Notable Deviations)
- `app/router.tsx` — wires the chats routes under the AppShell tree
## Notable deviations from the plan
Two intentional deviations, both explained in their commit bodies:
1. **`apps/fabro-web/app/root.tsx` no longer imports `./app.css`.**
Bun's CSS bundler (used by `Bun.build` on `entry.tsx`) rejects
spec-valid `@layer name, name;` ordering between `@import` rules, even
though Tailwind's CLI accepts it. The CSS is built standalone by the
Tailwind CLI step in `scripts/build.ts` and linked from
`index.template.html`, so dropping the JS-side import bypasses Bun's
parser without any runtime change. A safety-net comment at the top of
`app.css` warns future engineers against re-adding the import. Commit:
`c37690be9`.
2. **`!` non-null assertions removed** in two places where the verbatim
prototype copy violated the global CLAUDE.md rule banning `!` in
production code: `chats-script.ts` now uses a typed `FALLBACK_REPLY` and
`??` coalescing; `composer-chips.tsx` lifts the first option of each
chip into a `DEFAULT_*` constant. `chats-runtime.test.ts`'s `for await`
drain loops were also replaced with `Array.fromAsync(...)` per the
no-loops-in-tests rule. Commits: `ace6ac6d4`, `652ad97af`.
## Test plan
- [x] `cd apps/fabro-web && bun run typecheck` — clean
- [x] `bun test` — 383 pass / 0 fail (12 new tests for chats)
- [x] `cd apps/fabro-web && bun run build` — succeeds; assistant-ui CSS
bundled into `dist/assets/app.css`
- [x] **Manual browser smoke test** — debug `fabro` binary running this
branch served `/chats/new` and `/chats/seed_email` correctly inside the
real AppShell with GitHub-OAuth auth (screenshots above).
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
98 lines
3.5 KiB
TypeScript
98 lines
3.5 KiB
TypeScript
import { useState } from "react";
|
|
import {
|
|
Listbox,
|
|
ListboxButton,
|
|
ListboxOption,
|
|
ListboxOptions,
|
|
} from "@headlessui/react";
|
|
import {
|
|
ChevronUpDownIcon,
|
|
CheckIcon,
|
|
FolderIcon,
|
|
CpuChipIcon,
|
|
} from "@heroicons/react/16/solid";
|
|
|
|
type Choice = { id: string; label: string };
|
|
|
|
const DEFAULT_PROJECT: Choice = { id: "fabro-web", label: "fabro-web" };
|
|
const PROJECTS: Choice[] = [
|
|
DEFAULT_PROJECT,
|
|
{ id: "fabro-workflows", label: "fabro-workflows" },
|
|
{ id: "fabro-cli", label: "fabro-cli" },
|
|
];
|
|
|
|
const DEFAULT_BRANCH: Choice = { id: "main", label: "main" };
|
|
const BRANCHES: Choice[] = [
|
|
DEFAULT_BRANCH,
|
|
{ id: "develop", label: "develop" },
|
|
{ id: "feature/start-page", label: "feature/start-page" },
|
|
];
|
|
|
|
const DEFAULT_MODEL: Choice = { id: "claude-opus-4-7", label: "Claude Opus 4.7" };
|
|
const MODELS: Choice[] = [
|
|
DEFAULT_MODEL,
|
|
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
|
{ id: "gpt-5", label: "GPT-5" },
|
|
];
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
type IconComponent = React.ComponentType<{ className?: string }>;
|
|
|
|
function Chip({
|
|
options,
|
|
value,
|
|
onChange,
|
|
Icon,
|
|
}: {
|
|
options: Choice[];
|
|
value: Choice;
|
|
onChange: (c: Choice) => void;
|
|
Icon: IconComponent;
|
|
}) {
|
|
return (
|
|
<Listbox value={value} onChange={onChange}>
|
|
<div className="relative">
|
|
<ListboxButton className="inline-flex items-center gap-1.5 rounded-full bg-overlay px-2.5 py-1 text-xs font-medium text-fg-3 transition-colors hover:bg-overlay-strong hover:text-fg focus:outline-2 focus:outline-offset-1 focus:outline-teal-500">
|
|
<Icon className="size-3.5 text-fg-muted" />
|
|
<span>{value.label}</span>
|
|
<ChevronUpDownIcon className="size-3.5 text-fg-muted" />
|
|
</ListboxButton>
|
|
<ListboxOptions
|
|
anchor={{ to: "bottom start", gap: 6 }}
|
|
className="z-50 w-52 rounded-lg bg-panel p-1 shadow-2xl shadow-black/60 ring-1 ring-line-strong focus:outline-none"
|
|
>
|
|
{options.map((opt) => (
|
|
<ListboxOption
|
|
key={opt.id}
|
|
value={opt}
|
|
className="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm text-fg-2 data-focus:bg-overlay data-focus:text-fg data-selected:text-fg"
|
|
>
|
|
<CheckIcon className="invisible size-4 text-teal-300 group-data-selected:visible" />
|
|
<span>{opt.label}</span>
|
|
</ListboxOption>
|
|
))}
|
|
</ListboxOptions>
|
|
</div>
|
|
</Listbox>
|
|
);
|
|
}
|
|
|
|
export default function ComposerChips() {
|
|
const [project, setProject] = useState(DEFAULT_PROJECT);
|
|
const [branch, setBranch] = useState(DEFAULT_BRANCH);
|
|
const [model, setModel] = useState(DEFAULT_MODEL);
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<Chip options={PROJECTS} value={project} onChange={setProject} Icon={FolderIcon} />
|
|
<Chip options={BRANCHES} value={branch} onChange={setBranch} Icon={BranchIcon} />
|
|
<Chip options={MODELS} value={model} onChange={setModel} Icon={CpuChipIcon} />
|
|
</div>
|
|
);
|
|
}
|