|
| 1 | +import { Link } from "@tanstack/react-router"; |
| 2 | +import { useEffect, useRef, useState } from "react"; |
| 3 | +import { Badge, GalleryFrame } from "@/components/gallery/frame"; |
| 4 | +import { Button } from "@/components/ui/button"; |
| 5 | +import type { SkillFormValues } from "@/lib/skills/form"; |
| 6 | +import { |
| 7 | + checkProposal, |
| 8 | + type ProposedSkill, |
| 9 | + skillCardAnswer, |
| 10 | + wasSaved, |
| 11 | +} from "@/lib/skills/proposal"; |
| 12 | + |
| 13 | +/** |
| 14 | + * The skill a Bot has written, put in front of the person before it is saved. |
| 15 | + * |
| 16 | + * WHY THERE IS A CARD AT ALL, when a skill adds no capability and anybody signed in may write one. |
| 17 | + * Two reasons, and neither is about permission. The first is authorship: this is a named thing that |
| 18 | + * will appear in everybody's `/` menu with somebody's name on it, and a person should read the |
| 19 | + * instruction their Bot drafted before it starts running on their behalf. The second is the slug: |
| 20 | + * saving is how an edit is spelled, so an unattended save can replace a skill somebody is using. |
| 21 | + * |
| 22 | + * So the whole tool is this card. There is no handler behind it — the run suspends here, and nothing |
| 23 | + * is written until a button is pressed. |
| 24 | + */ |
| 25 | + |
| 26 | +export type ProposedSkillCardProps = { |
| 27 | + /** Partial while the model is still streaming the arguments. */ |
| 28 | + args: Partial<ProposedSkill>; |
| 29 | + /** |
| 30 | + * What this slug already names here, when it names something. |
| 31 | + * |
| 32 | + * Drives the wording rather than the outcome — "Replace" instead of "Create", and whose it is. |
| 33 | + * The server decides whether the replacement is allowed and says so in its own words. |
| 34 | + */ |
| 35 | + replaces?: { title: string; ownership: string }; |
| 36 | + /** Answering resumes the Bot. Absent while streaming, and once this card has been answered. */ |
| 37 | + respond?: (result: unknown) => Promise<void>; |
| 38 | + /** The recorded answer, once there is one. Completed cards show it instead of controls. */ |
| 39 | + result?: string; |
| 40 | + save: (values: SkillFormValues) => Promise<void>; |
| 41 | +}; |
| 42 | + |
| 43 | +export function ProposedSkillCard({ |
| 44 | + args, |
| 45 | + replaces, |
| 46 | + respond, |
| 47 | + result, |
| 48 | + save, |
| 49 | +}: ProposedSkillCardProps) { |
| 50 | + const [sending, setSending] = useState<"save" | "decline" | null>(null); |
| 51 | + /** |
| 52 | + * A refusal from the server, kept on the card rather than answered with. |
| 53 | + * |
| 54 | + * The run stays suspended, because the two things worth doing next both need it to be — pressing |
| 55 | + * Create again after connecting the connector the refusal named, or declining. Answering the tool |
| 56 | + * with the error would end the turn and leave the person re-typing their request. |
| 57 | + */ |
| 58 | + const [refusal, setRefusal] = useState<string | null>(null); |
| 59 | + |
| 60 | + if (result !== undefined) { |
| 61 | + return ( |
| 62 | + <GalleryFrame |
| 63 | + action={<Badge tone="neutral">Done</Badge>} |
| 64 | + title={titleFor(args.slug, replaces)} |
| 65 | + > |
| 66 | + <p className="text-sm">{result}</p> |
| 67 | + {args.slug && wasSaved(result) ? ( |
| 68 | + <PutItOnABot slug={args.slug} /> |
| 69 | + ) : null} |
| 70 | + </GalleryFrame> |
| 71 | + ); |
| 72 | + } |
| 73 | + |
| 74 | + if (!respond) { |
| 75 | + return ( |
| 76 | + <GalleryFrame title={titleFor(args.slug, replaces)}> |
| 77 | + <p className="text-sm text-muted-foreground">Writing the skill…</p> |
| 78 | + </GalleryFrame> |
| 79 | + ); |
| 80 | + } |
| 81 | + |
| 82 | + const checked = checkProposal(args); |
| 83 | + if (!checked.ok) { |
| 84 | + return <Unwritable problems={checked.problems} respond={respond} />; |
| 85 | + } |
| 86 | + const values = checked.values; |
| 87 | + |
| 88 | + const create = async () => { |
| 89 | + setSending("save"); |
| 90 | + setRefusal(null); |
| 91 | + try { |
| 92 | + await save(values); |
| 93 | + } catch (cause) { |
| 94 | + // Left on the card with the buttons still live. See `refusal` above. |
| 95 | + setSending(null); |
| 96 | + setRefusal( |
| 97 | + cause instanceof Error ? cause.message : "The skill was not saved.", |
| 98 | + ); |
| 99 | + return; |
| 100 | + } |
| 101 | + await answer(respond, skillCardAnswer.saved(values.slug)); |
| 102 | + }; |
| 103 | + |
| 104 | + const decline = async () => { |
| 105 | + setSending("decline"); |
| 106 | + await answer(respond, skillCardAnswer.declined()); |
| 107 | + }; |
| 108 | + |
| 109 | + return ( |
| 110 | + <GalleryFrame |
| 111 | + action={<Badge tone="caution">Waiting on you</Badge>} |
| 112 | + caption={ |
| 113 | + replaces |
| 114 | + ? `This replaces ${replaces.title}, which is ${replaces.ownership}.` |
| 115 | + : "Nothing is saved until you press the button." |
| 116 | + } |
| 117 | + title={titleFor(values.slug, replaces)} |
| 118 | + > |
| 119 | + <dl className="grid grid-cols-[minmax(0,7rem)_1fr] gap-x-4 gap-y-1.5 text-sm"> |
| 120 | + <dt className="truncate text-muted-foreground">Command</dt> |
| 121 | + <dd className="min-w-0 break-words font-mono">/{values.slug}</dd> |
| 122 | + <dt className="truncate text-muted-foreground">Title</dt> |
| 123 | + <dd className="min-w-0 break-words">{values.title}</dd> |
| 124 | + {values.summary ? ( |
| 125 | + <> |
| 126 | + <dt className="truncate text-muted-foreground">One-liner</dt> |
| 127 | + <dd className="min-w-0 break-words">{values.summary}</dd> |
| 128 | + </> |
| 129 | + ) : null} |
| 130 | + {values.tools.length > 0 ? ( |
| 131 | + <> |
| 132 | + <dt className="truncate text-muted-foreground">Needs</dt> |
| 133 | + <dd className="min-w-0 break-words font-mono text-xs"> |
| 134 | + {values.tools.join(", ")} |
| 135 | + </dd> |
| 136 | + </> |
| 137 | + ) : null} |
| 138 | + </dl> |
| 139 | + |
| 140 | + <p className="mt-3 text-muted-foreground text-xs">Instructions</p> |
| 141 | + {/* Scrolled rather than clamped: this is the part worth reading before agreeing to it. */} |
| 142 | + <p className="mt-1 max-h-56 overflow-y-auto whitespace-pre-wrap rounded-md border border-border bg-muted/40 px-3 py-2 text-sm"> |
| 143 | + {values.instructions} |
| 144 | + </p> |
| 145 | + |
| 146 | + {values.tools.length > 0 ? ( |
| 147 | + <p className="mt-2 text-muted-foreground text-xs"> |
| 148 | + Naming a tool grants nothing. A Bot is offered these only if an |
| 149 | + administrator has already granted them to it. |
| 150 | + </p> |
| 151 | + ) : null} |
| 152 | + |
| 153 | + {refusal ? ( |
| 154 | + <p className="mt-3 text-destructive text-sm" role="alert"> |
| 155 | + {refusal} |
| 156 | + </p> |
| 157 | + ) : null} |
| 158 | + |
| 159 | + <div className="mt-4 flex gap-2"> |
| 160 | + <Button |
| 161 | + disabled={Boolean(sending)} |
| 162 | + onClick={() => void create()} |
| 163 | + size="sm" |
| 164 | + > |
| 165 | + {sending === "save" |
| 166 | + ? "Saving…" |
| 167 | + : replaces |
| 168 | + ? "Replace it" |
| 169 | + : "Create it"} |
| 170 | + </Button> |
| 171 | + <Button |
| 172 | + disabled={Boolean(sending)} |
| 173 | + onClick={() => void decline()} |
| 174 | + size="sm" |
| 175 | + variant="outline" |
| 176 | + > |
| 177 | + {sending === "decline" ? "…" : "Don't save"} |
| 178 | + </Button> |
| 179 | + </div> |
| 180 | + </GalleryFrame> |
| 181 | + ); |
| 182 | +} |
| 183 | + |
| 184 | +function titleFor( |
| 185 | + slug: string | undefined, |
| 186 | + replaces: { title: string } | undefined, |
| 187 | +): string { |
| 188 | + const name = slug ? `/${slug}` : "a new skill"; |
| 189 | + return replaces ? `Replace ${name}` : `Create ${name}`; |
| 190 | +} |
| 191 | + |
| 192 | +/** Where the remaining step happens. A skill on no Bot is inert, and saying so beats implying done. */ |
| 193 | +function PutItOnABot({ slug }: { slug: string }) { |
| 194 | + return ( |
| 195 | + <Link |
| 196 | + className="mt-2 inline-block text-sm underline underline-offset-4" |
| 197 | + search={{ edit: slug }} |
| 198 | + to="/skills" |
| 199 | + > |
| 200 | + Put it on a Bot |
| 201 | + </Link> |
| 202 | + ); |
| 203 | +} |
| 204 | + |
| 205 | +/** |
| 206 | + * A proposal that cannot be saved as written. |
| 207 | + * |
| 208 | + * Answered rather than shown as a question, because there is nothing for the person to decide: the |
| 209 | + * fields are wrong in a way the model can fix, and the problems name their field so it can. The card |
| 210 | + * still appears, so the transcript does not have a silent gap where a skill was nearly written. |
| 211 | + */ |
| 212 | +function Unwritable({ |
| 213 | + problems, |
| 214 | + respond, |
| 215 | +}: { |
| 216 | + problems: string[]; |
| 217 | + respond: (result: unknown) => Promise<void>; |
| 218 | +}) { |
| 219 | + /** |
| 220 | + * Answered once, guarded by a ref rather than by the dependency list. |
| 221 | + * |
| 222 | + * `problems` is rebuilt from the arguments on every render and the grant queries behind this card |
| 223 | + * poll, so a value-equal array arrives as a new identity every few seconds. Left to the deps, this |
| 224 | + * would answer the same tool call again on each of them. |
| 225 | + */ |
| 226 | + const answered = useRef(false); |
| 227 | + useEffect(() => { |
| 228 | + if (answered.current) return; |
| 229 | + answered.current = true; |
| 230 | + void answer(respond, skillCardAnswer.unwritable(problems)); |
| 231 | + }, [problems, respond]); |
| 232 | + |
| 233 | + return ( |
| 234 | + <GalleryFrame |
| 235 | + action={<Badge tone="negative">Not saved</Badge>} |
| 236 | + title="Skill" |
| 237 | + > |
| 238 | + <p className="text-sm"> |
| 239 | + The Bot's draft was not a valid skill, so nothing was saved. It has been |
| 240 | + told what to fix. |
| 241 | + </p> |
| 242 | + </GalleryFrame> |
| 243 | + ); |
| 244 | +} |
| 245 | + |
| 246 | +/** Resuming a run that is no longer there is not a failure worth reporting: nothing is left to answer. */ |
| 247 | +function answer( |
| 248 | + respond: (result: unknown) => Promise<void>, |
| 249 | + sentence: string, |
| 250 | +): Promise<void> { |
| 251 | + return respond(sentence).catch(() => {}); |
| 252 | +} |
0 commit comments