diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index dd3abf74c..f06802fed 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -3,12 +3,16 @@ name: Desktop # The shell is the one thing here that cannot be proved by running it on Linux: it is a macOS app, a # Windows app and a Linux app built from one tree, and the ways they differ are exactly the ways # this fails. So it tests and builds on all three, on every change to it. +# `shared/harnesses.json` and `shared/marks/**` are watched here even though they are not under +# desktop/: the catalogue the setup window draws moved there so the web app could read one copy, and +# these jobs are the only ones that prove it still deserializes into `Harness` — `catalogue()` parses +# it at runtime with an `expect`, so a bad row panics the window rather than failing a type check. on: pull_request: - paths: ["desktop/**", "package.json", ".github/workflows/desktop.yml"] + paths: ["desktop/**", "shared/harnesses.json", "shared/marks/**", "package.json", ".github/workflows/desktop.yml"] push: branches: [main] - paths: ["desktop/**", "package.json", ".github/workflows/desktop.yml"] + paths: ["desktop/**", "shared/harnesses.json", "shared/marks/**", "package.json", ".github/workflows/desktop.yml"] workflow_call: secrets: OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_SECRET: diff --git a/app/src/components/agents/agent-profile.tsx b/app/src/components/agents/agent-profile.tsx index 5065173c5..6d77bf57d 100644 --- a/app/src/components/agents/agent-profile.tsx +++ b/app/src/components/agents/agent-profile.tsx @@ -89,7 +89,7 @@ export function AgentProfile({ agentId }: { agentId: string }) {

- Role + What it does

{profile.roleDescription} diff --git a/app/src/components/agents/create-agent-dialog.tsx b/app/src/components/agents/create-agent-dialog.tsx index ff06cf169..4c20c50e2 100644 --- a/app/src/components/agents/create-agent-dialog.tsx +++ b/app/src/components/agents/create-agent-dialog.tsx @@ -1,7 +1,10 @@ +import { summaryFor, WEB_HARNESSES } from "@openbot/shared/harnesses"; +import { IconSparkles } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { AnimatePresence, MotionConfig, motion } from "motion/react"; import { useState } from "react"; import useMeasure from "react-use-measure"; +import { HarnessMark } from "@/components/agents/harness-mark"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -61,7 +64,13 @@ export function CreateAgentDialog({ }) { return (

!next && onClose()} open={open}> - + {/* A floor of 70% of the viewport, against the popup's usual grow-to-fit. The three steps are + very different heights — two choices on one, three fields on another, four plus an + endpoint test on the last — and a dialog that resized itself between each made the + wizard feel like three unrelated popups. The floor sits under the 85svh ceiling the popup + already carries, so the two never fight: every step lands in that 70–85% band, and the + shortest one pays for it with empty room below the question. */} + {/* All wizard state lives below DialogContent, whose portal unmounts on close: dismissing the dialog mid-way discards the half-answered steps rather than pickling them. */} @@ -70,8 +79,14 @@ export function CreateAgentDialog({ ); } -/** The steps, in the order they are asked. The name is the questionnaire item's name. */ -const STEPS = ["identity", "visibility", "kind"] as const; +/** + * The steps, in the order they are asked. The name is the questionnaire item's name. + * + * Visibility is last because it is the only question whose answer does not change what the person + * is making. Asked second, it arrived before they had decided anything that makes private or public + * mean something, and it was the step people had the least to go on. + */ +const STEPS = ["identity", "kind", "endpoint", "visibility"] as const; type StepName = (typeof STEPS)[number]; /** The two ways a coworker can be seen. */ @@ -95,28 +110,13 @@ const VISIBILITY_OPTIONS: Array<{ /** * Where the coworker runs. Not a stored field: the server knows only whether an endpoint was * given, so "built-in" is the empty endpoint and this choice exists to make that fork explicit. + * + * Which framework was picked is not stored either. Every one of them is reached the same way and + * written the same way, so after this dialog closes there is no record of the button that was + * pressed. */ type AgentKind = "builtin" | "managed"; -const KIND_OPTIONS: Array<{ - value: AgentKind; - title: string; - description: string; -}> = [ - { - value: "builtin", - title: "Built-in", - description: - "Runs on this deployment's own Bot. Nothing to host or connect — it is ready the moment it is created.", - }, - { - value: "managed", - title: "Managed", - description: - "Runs on an agent you host, spoken to over AG-UI. This server dials your endpoint on every run.", - }, -]; - /** The first step's slice of the form contract, so its errors match the server's limits. */ const identitySchema = agentFormSchema.pick({ name: true, @@ -170,12 +170,30 @@ function CreateAgentWizard({ const [values, setValues] = useState(emptyAgentForm); /** Deliberately unanswered to start with: revealing endpoint fields is the point of asking. */ const [kind, setKind] = useState(null); + /** + * Which framework's button was pressed, so Back returns to it still chosen. + * + * Nothing else reads it. Every framework leads to the same endpoint step and is stored the same + * way, so after this dialog closes there is no answer to "which did I pick" — by design. + */ + const [harness, setHarness] = useState(null); const [connection, setConnection] = useState(null); const [testing, setTesting] = useState(false); const [ref, bounds] = useMeasure(); - const last = step === STEPS.length - 1; + /** + * The steps this answer actually asks, which is why there is no counter. + * + * A Bot that runs on this deployment is never asked for an address, so the sequence is three + * questions for it and four for one somebody hosts. `step` indexes this rather than `STEPS`, so + * the same index means the same question whichever way the person went. + */ + const sequence = STEPS.filter( + (name) => name !== "endpoint" || kind === "managed", + ); + const current = sequence[step]; + const last = step === sequence.length - 1; const set = ( key: K, value: AgentFormValues[K], @@ -197,18 +215,39 @@ function CreateAgentWizard({ const identityErrors = tried ? identityIssues(values) : {}; const endpointError = !tried ? undefined - : kind === "managed" && values.endpoint.trim() === "" - ? "An endpoint is required for a managed coworker." + : values.endpoint.trim() === "" + ? "An address is required for an agent you host." : agentFormSchema.shape.endpoint.safeParse(values.endpoint).error ?.issues[0]?.message; + /** + * A framework button, or the Bot that runs here, answered and moved on in one press. + * + * The press is the answer, so there is nothing left for a Continue to confirm — and the step + * this replaced cost the hosted path two presses for one decision. `go` is called with the next + * index directly rather than through `advance`, because `advance` would read `kind` from a + * closure this setState has not updated yet. + */ + const choose = (next: AgentKind, harnessId: string | null) => { + setKind(next); + setHarness(harnessId); + if (next === "builtin") { + // A built-in coworker has no endpoint; whatever was typed on the way past must not ride + // along into the create. + set("endpoint", ""); + set("authValue", ""); + setConnection(null); + } + go(step + 1); + }; + const stepValid = (): boolean => { - if (STEPS[step] === "identity") { + if (current === "identity") { return identitySchema.safeParse(values).success; } - if (STEPS[step] === "kind") { - if (kind === null) return false; - if (kind === "builtin") return true; + // Reachable only by pressing one of its buttons, which is the answer; guarded anyway for Enter. + if (current === "kind") return kind !== null; + if (current === "endpoint") { return ( values.endpoint.trim() !== "" && agentFormSchema.shape.endpoint.safeParse(values.endpoint).success @@ -248,7 +287,10 @@ function CreateAgentWizard({ New coworker event.preventDefault()} > - {/* A plain element rather than QuestionnaireProgress: only the active step is mounted, - so the primitive would count one question and announce the wrong total. */} -

- Step {step + 1} of {STEPS.length} -

+ {/* No step counter. The wizard is three questions for a Bot that runs here and four for + one at an address somebody hosts, and a counter whose total changes under the reader + mid-way is worse than none: it reads as the thing having got longer. Each step says + what it is asking in its own heading, which is the part that was carrying the reader + anyway. */} @@ -297,43 +339,45 @@ function CreateAgentWizard({ > - {STEPS[step] === "identity" ? ( + {current === "identity" ? ( - ) : STEPS[step] === "visibility" ? ( + ) : current === "visibility" ? ( - ) : ( - { - setKind(next); - if (next === "builtin") { - // A built-in coworker has no endpoint; whatever was typed on the way - // past must not ride along into the create. - set("endpoint", ""); - set("authValue", ""); - setConnection(null); - } - }} onTest={() => void testConnection()} - connection={connection} set={set} - showKindError={tried && kind === null} testing={testing} values={values} /> + ) : ( + )} @@ -347,7 +391,9 @@ function CreateAgentWizard({

) : null} -
+ {/* `mt-auto` pins the row to the bottom when the step is short; `pt-6` is the gap it + keeps once the step is tall enough to have pushed it there itself. */} +
- + {/* No forward button where the choice is the answer: on "where does it run" every + button both answers and moves on, so a Continue beside them would be a + control that confirms a decision already taken. */} + {current === "kind" ? null : ( + + )}
@@ -411,13 +462,14 @@ function IdentityStep({ Who is this coworker? - The role you write here applies in every channel this coworker works in. + A name to call it by, a line describing it, and the job it does. Name set("name", event.target.value)} placeholder="Expense Manager" @@ -431,6 +483,7 @@ function IdentityStep({ Title set("title", event.target.value)} placeholder="Finance Operations" @@ -438,12 +491,17 @@ function IdentityStep({ /> {errors.title ? ( - ) : null} + ) : ( +

+ A few words, shown beneath its name in lists. +

+ )}
- Role + What it does