Skip to the content

Capsid menu

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

The functions of ai-draft.tsx
FunctionLinesComplexityCoveredCRAPMutation
meta30 to 3210%OkCRAP 2No data: mutation testing has not run on this repo yet
draftId34 to 38375%OkCRAP 3.1No data: mutation testing has not run on this repo yet
loader40 to 531100%OkCRAP 1No data: mutation testing has not run on this repo yet
action55 to 621100%OkCRAP 1No data: mutation testing has not run on this repo yet
AiDraft64 to 16580%CriticalCRAP 72No data: mutation testing has not run on this repo yet
<anonymous:91>91 to 9410%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:130>130 to 13240%WatchCRAP 20No data: mutation testing has not run on this repo yet
<anonymous:157>157 to 16010%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:161>161 to 16110%OkCRAP 2No 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>
  );
}