app/routes/ai-draft.tsx
156 lines and 9 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| meta | 30 to 32 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| draftId | 34 to 38 | 3 | 75% | OkCRAP 3.1 | No data: mutation testing has not run on this repo yet |
| loader | 40 to 53 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| action | 55 to 62 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| AiDraft | 64 to 165 | 8 | 0% | CriticalCRAP 72 | No data: mutation testing has not run on this repo yet |
| <anonymous:91> | 91 to 94 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:130> | 130 to 132 | 4 | 0% | WatchCRAP 20 | No data: mutation testing has not run on this repo yet |
| <anonymous:157> | 157 to 160 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:161> | 161 to 161 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
Imports
It imports app/components/editor/frontmatter-fields.tsx, app/components/frame.tsx, app/lib/ai.server.ts, app/lib/content.server.ts, app/lib/context.ts, app/lib/projects.server.ts, app/lib/roles.ts, app/lib/voice.server.ts. No module of this repo imports it.
Source
// One AI draft, read-only, beside the person's own. Using it is the person's decision: "Use as my
// draft" puts its text in their working draft (autosave), where they edit it before anything reaches
// the site. Nothing here writes to the site.
import { useRef, useState } from "react";
import { Form, Link, redirect, useNavigation } from "react-router";
import { Banner } from "capsomer/react/banner";
import { Button } from "capsomer/react/button";
import { ConfirmDialog } from "capsomer/react/confirm-dialog";
import { Empty } from "capsomer/react/empty";
import { Panel } from "capsomer/react/panel";
import { Row, RowList } from "capsomer/react/row-list";
import { Segmented } from "capsomer/react/segmented";
import { Status } from "capsomer/react/status";
import { Time } from "capsomer/react/time";
import { PageHead } from "capsomer/react/page-head";
import { FrontmatterFields } from "~/components/editor/frontmatter-fields";
import { RouterLink } from "~/components/frame";
import { readAiDraft } from "~/lib/ai.server";
import { autosave } from "~/lib/content.server";
import { getEnv, getViewer } from "~/lib/context";
import { requireSiteProject } from "~/lib/projects.server";
import { can } from "~/lib/roles";
import { checkAgainstPack, readVoicePack } from "~/lib/voice.server";
import type { Route } from "./+types/ai-draft";
export function meta() {
return [{ title: "AI draft · Carrel" }];
}
function draftId(value: string | undefined): number {
const id = Number(value);
if (!Number.isInteger(id) || id <= 0) throw new Response("Not found", { status: 404 });
return id;
}
export async function loader({ params, context }: Route.LoaderArgs) {
const env = getEnv(context);
const viewer = getViewer(context);
const project = await requireSiteProject(env.DB, viewer, params.project, "read");
const draft = await readAiDraft(env.DB, project, viewer, params.item, draftId(params.id));
return {
project: { slug: project.slug, name: project.name },
itemId: params.item,
draft: { id: draft.id, client: draft.client, note: draft.note, createdAt: draft.createdAt, source: draft.source, baseVersion: draft.baseVersion },
canEdit: can(project.role, "edit"),
// The writing checks against this project's voice pack, run on every view: the pack may have changed since it was saved.
checks: checkAgainstPack(await readVoicePack(env.DB, project), draft.source),
};
}
export async function action({ params, context }: Route.ActionArgs) {
const env = getEnv(context);
const viewer = getViewer(context);
const project = await requireSiteProject(env.DB, viewer, params.project, "edit");
const draft = await readAiDraft(env.DB, project, viewer, params.item, draftId(params.id));
await autosave(env.DB, project, viewer, params.item, { source: draft.source, baseVersion: draft.baseVersion });
return redirect(`/p/${project.slug}/e/${encodeURIComponent(params.item)}`);
}
export default function AiDraft({ loaderData }: Route.ComponentProps) {
const { project, itemId, draft, canEdit, checks } = loaderData;
const editor = `/p/${project.slug}/e/${encodeURIComponent(itemId)}`;
const busy = useNavigation().state !== "idle";
const [asking, setAsking] = useState<HTMLElement | null>(null);
const [view, setView] = useState<"rendered" | "source">("rendered");
const form = useRef<HTMLFormElement>(null);
return (
<div className="app-page">
<PageHead
renderLink={RouterLink}
crumbs={[{ label: project.name, href: `/p/${project.slug}` }, { label: itemId, href: editor }, { label: "AI draft" }]}
title={`AI draft from ${draft.client}`}
lead={
<>
Saved <Time at={draft.createdAt} format="exact" />
{draft.note ? ` · ${draft.note}` : ""}
</>
}
actions={
<>
{canEdit ? (
<Form method="post" ref={form}>
<Button
type="submit"
variant="primary"
pending={busy}
onClick={(event) => {
event.preventDefault();
setAsking(event.currentTarget);
}}
>
Use as my draft
</Button>
</Form>
) : null}
<Link to={`${editor}/history?compare=ai&ai=${draft.id}`} className="cap-btn">
Compare with my draft
</Link>
<Link to={editor} className="cap-btn">
Back to my draft
</Link>
</>
}
/>
<Banner tone="info">
Written by an AI session, beside your own draft. It has not changed your draft or the site. Using it replaces your working draft in Carrel with this text, which you then edit
and save to the site as usual.
</Banner>
<FrontmatterFields source={draft.source} readOnly />
<Panel title="Writing checks" count={checks.issues.length} src="Against the project's Voice page" flush>
{checks.issues.length === 0 && !checks.voice ? (
<Empty kind="all-clear" flush title="Nothing flagged">
No stock AI phrasing found. Read it anyway: the checks catch habits, not whether it says something.
</Empty>
) : (
<RowList label="Writing checks on this draft">
{checks.voice ? (
<Row
status={checks.voice.unlike ? <Status tone="warn">Unlike your posts</Status> : <Status tone="ok">Like your posts</Status>}
title={`Distance ${checks.voice.distance.toFixed(2)} from your published posts, flagged from ${checks.voice.threshold}`}
detail={`${checks.voice.furthest.join("; ")}.`}
href={`/p/${project.slug}/voice`}
renderLink={RouterLink}
/>
) : null}
{checks.issues.map((issue, i) => (
<Row key={i} title={issue.message} detail={issue.excerpt && !issue.message.includes(`"${issue.excerpt}"`) ? <q>{issue.excerpt}</q> : undefined} meta={issue.line ? `Line ${issue.line}` : undefined} />
))}
</RowList>
)}
</Panel>
<Panel
title="The draft"
src={`${draft.source.trim().split(/\s+/).filter(Boolean).length.toLocaleString()} words`}
actions={<Segmented legend="View" hideLegend size="sm" value={view} onChange={setView} options={[{ value: "rendered", label: "As it reads" }, { value: "source", label: "Source" }]} />}
>
{view === "rendered" ? (
<div className="app-preview-pane">
{/* Sandboxed with no permissions, as the editor's preview is: the site's page runs no script. */}
<iframe title={`This AI draft as the site renders it`} src={`${editor}/ai/${draft.id}/preview`} sandbox="" />
</div>
) : (
<pre className="app-ai-draft">{draft.source}</pre>
)}
</Panel>
<ConfirmDialog
open={asking !== null}
title="Use the AI draft as your draft?"
lead="Its text replaces your working draft in Carrel, and your current draft is not kept. Nothing is sent to the site: the text waits in the editor until you save it there."
body={[]}
action="Replace my draft"
returnTo={asking}
perform={async () => {
// Submitting the form itself does not press the button again, so it does not ask twice.
form.current?.requestSubmit();
}}
onClose={() => setAsking(null)}
/>
</div>
);
}