Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions web_ui/src/app/chrome/BuilderLaunchDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,32 @@ describe("BuilderLaunchDialog", () => {
expect(screen.getByRole("note")).toHaveTextContent(/network access will still ask/i);
});

it("marks only the chosen oversight row selected (drives its highlight)", async () => {
const { user } = await setup();
const copilot = screen.getByRole("radio", { name: /co-pilot/i });
const autopilot = screen.getByRole("radio", { name: /autopilot/i });

expect(copilot.closest("label")).toHaveClass("selected");
expect(autopilot.closest("label")).not.toHaveClass("selected");

await user.click(autopilot);

expect(autopilot.closest("label")).toHaveClass("selected");
expect(copilot.closest("label")).not.toHaveClass("selected");
});

it("shows the selected recipe's own description", async () => {
const { user } = await setup("n9", [
{ name: "Research and summarize", description: "Researches a topic, then writes a summary.", builtIn: true },
]);

await user.selectOptions(screen.getByLabelText("Recipe"), "Research and summarize");

expect(
await screen.findByText("Researches a topic, then writes a summary."),
).toBeInTheDocument();
});

it("a null start result (backend refused) shows an error instead of closing", async () => {
const { user } = await setup(null);

Expand Down
155 changes: 90 additions & 65 deletions web_ui/src/app/chrome/BuilderLaunchDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,57 +86,76 @@ export function BuilderLaunchDialog({ transport }: { transport: WsTransport }) {
});
}

// The selected recipe's own description - listRecipes has always
// returned it, but nothing ever rendered it, so the picker gave no clue
// what a recipe actually builds until after launching it.
const selectedRecipe = recipes.find((r) => r.name === recipe) ?? null;

return (
<Dialog name="builder-launch" title="Builder" className="builder-launch-dialog">
<label className="builder-launch-label" htmlFor="builder-recipe">
Recipe
</label>
<select
id="builder-recipe"
className="builder-launch-recipe"
value={recipe}
onChange={(event) => setRecipe(event.target.value)}
>
<option value="">Start from scratch</option>
{recipes.map((r) => (
<option key={r.name} value={r.name}>
{r.name}
{r.builtIn ? " (built-in)" : ""}
</option>
))}
</select>
<div className="builder-launch-field">
<label className="builder-launch-label" htmlFor="builder-recipe">
Recipe
</label>
<select
id="builder-recipe"
className="builder-launch-select"
value={recipe}
onChange={(event) => setRecipe(event.target.value)}
>
<option value="">Start from scratch</option>
{recipes.map((r) => (
<option key={r.name} value={r.name}>
{r.name}
{r.builtIn ? " (built-in)" : ""}
</option>
))}
</select>
<p className="builder-launch-hint">
{selectedRecipe?.description || "Describe a build yourself, or pick a saved recipe."}
</p>
</div>

<label className="builder-launch-label" htmlFor="builder-goal">
What should the Builder construct?
</label>
<textarea
id="builder-goal"
className="builder-launch-goal"
placeholder="e.g. Research recent solar output trends, compute the growth rate, and chart it"
value={goal}
onChange={(event) => setGoal(event.target.value)}
rows={3}
/>
<div className="builder-launch-field">
<label className="builder-launch-label" htmlFor="builder-goal">
What should the Builder construct?
</label>
<textarea
id="builder-goal"
className="builder-launch-goal"
placeholder="e.g. Research recent solar output trends, compute the growth rate, and chart it"
value={goal}
onChange={(event) => setGoal(event.target.value)}
rows={3}
/>
{recipe && (
<p className="builder-launch-hint">
Optional - the recipe carries its own goal. Anything typed here is added to it.
</p>
)}
</div>

<fieldset className="builder-launch-mode">
<fieldset className="builder-launch-fieldset">
<legend>Oversight</legend>
<label>
<label className={`builder-launch-choice${mode === "copilot" ? " selected" : ""}`}>
<input
type="radio"
name="builder-mode"
checked={mode === "copilot"}
onChange={() => setMode("copilot")}
/>
Co-pilot — approve every mutating step
<span className="builder-launch-choice-title">Co-pilot</span>
<span className="builder-launch-choice-desc">Approve every mutating step.</span>
</label>
<label>
<label className={`builder-launch-choice${mode === "autopilot" ? " selected" : ""}`}>
<input
type="radio"
name="builder-mode"
checked={mode === "autopilot"}
onChange={() => setMode("autopilot")}
/>
Autopilot — run to completion within the budgets
<span className="builder-launch-choice-title">Autopilot</span>
<span className="builder-launch-choice-desc">Run to completion within the budgets.</span>
</label>
{mode === "autopilot" && (
<p className="builder-launch-disclosure" role="note">
Expand All @@ -147,38 +166,44 @@ export function BuilderLaunchDialog({ transport }: { transport: WsTransport }) {
)}
</fieldset>

<fieldset className="builder-launch-budgets">
<legend>Budgets (hard limits — a breach pauses the build)</legend>
<label>
Max steps
<input
type="number"
min={1}
max={50}
value={maxSteps}
onChange={(event) => setMaxSteps(Number(event.target.value) || DEFAULT_MAX_STEPS)}
/>
</label>
<label>
Max tokens
<input
type="number"
min={1000}
step={10_000}
value={maxTokens}
onChange={(event) => setMaxTokens(Number(event.target.value) || DEFAULT_MAX_TOKENS)}
/>
</label>
<label>
Max seconds
<input
type="number"
min={30}
step={60}
value={maxWallSeconds}
onChange={(event) => setMaxWallSeconds(Number(event.target.value) || DEFAULT_MAX_WALL_SECONDS)}
/>
</label>
<fieldset className="builder-launch-fieldset">
<legend>Budgets</legend>
<p className="builder-launch-hint">Hard limits - a breach pauses the build.</p>
<div className="builder-launch-budgets">
<label className="builder-launch-budget">
<span className="builder-launch-budget-label">Max steps</span>
<input
className="builder-launch-number"
type="number"
min={1}
max={50}
value={maxSteps}
onChange={(event) => setMaxSteps(Number(event.target.value) || DEFAULT_MAX_STEPS)}
/>
</label>
<label className="builder-launch-budget">
<span className="builder-launch-budget-label">Max tokens</span>
<input
className="builder-launch-number"
type="number"
min={1000}
step={10_000}
value={maxTokens}
onChange={(event) => setMaxTokens(Number(event.target.value) || DEFAULT_MAX_TOKENS)}
/>
</label>
<label className="builder-launch-budget">
<span className="builder-launch-budget-label">Max seconds</span>
<input
className="builder-launch-number"
type="number"
min={30}
step={60}
value={maxWallSeconds}
onChange={(event) => setMaxWallSeconds(Number(event.target.value) || DEFAULT_MAX_WALL_SECONDS)}
/>
</label>
</div>
</fieldset>

{error && (
Expand Down
133 changes: 101 additions & 32 deletions web_ui/src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -6768,62 +6768,131 @@ mark.document-view-search-match-current {
.plan-node-button-stop { border-color: var(--gl-semantic-status-error); }
.plan-node-button-deny { border-color: var(--gl-semantic-status-warning); }

.builder-launch-dialog {
/* <Dialog>'s own `className` lands on the PANEL (.overlay-dialog), never on
the body - so a dialog's form layout has to be scoped to
.overlay-dialog-body here, exactly the way .settings-dialog/.help-dialog/
.library-dialog already scope theirs. This block previously put the
column layout on .builder-launch-dialog itself, which only ever spaced
the header away from the body: the body stayed a plain block, so the
Recipe label, its <select>, and the goal label - all inline-level - ran
together on one line before the full-width <textarea> broke it. */
.builder-launch-dialog { width: 540px; }

.builder-launch-dialog .overlay-dialog-body {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 420px;
gap: 16px;
}

.builder-launch-field { display: flex; flex-direction: column; gap: 6px; }
.builder-launch-label { font-size: 13px; font-weight: 600; }
.builder-launch-recipe {
font: inherit;
padding: 5px 8px;
background: var(--gl-surface-node-body);
.builder-launch-hint { margin: 0; font-size: 11px; color: var(--gl-surface-text-muted); }

/* One shared control box for all three input kinds - the number inputs
previously had no styling at all beyond a width, so they rendered as
stark default-white browser fields inside a dark dialog. */
.builder-launch-select,
.builder-launch-goal,
.builder-launch-number {
box-sizing: border-box;
width: 100%;
font-family: inherit;
font-size: 13px;
padding: 8px;
color: var(--gl-surface-text-primary);
background-color: var(--gl-surface-inset, var(--gl-surface-window));
border: 1px solid var(--gl-surface-border);
border-radius: 6px;
}
.builder-launch-goal {
width: 100%;
resize: vertical;
font: inherit;
padding: 6px 8px;
box-sizing: border-box;
background: var(--gl-surface-node-body);
color: var(--gl-surface-text-primary);
.builder-launch-goal { resize: vertical; min-height: 72px; line-height: 1.45; }

.builder-launch-select:focus-visible,
.builder-launch-goal:focus-visible,
.builder-launch-number:focus-visible {
outline: 2px solid var(--gl-focus-ring);
outline-offset: 1px;
}

.builder-launch-fieldset {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 12px;
border: 1px solid var(--gl-surface-border);
border-radius: 6px;
}
.builder-launch-mode, .builder-launch-budgets {
border: 1px solid var(--gl-surface-border);
.builder-launch-fieldset legend {
padding: 0 4px;
font-size: 12px;
color: var(--gl-surface-text-muted);
}

/* Whole-row radio targets (title + description), so the click area is the
card rather than the 13px control itself. */
.builder-launch-choice {
display: grid;
grid-template-columns: auto 1fr;
column-gap: 9px;
row-gap: 2px;
align-items: start;
padding: 8px 9px;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
}
.builder-launch-choice:hover { background-color: var(--gl-neutral-button-hover); }
.builder-launch-choice.selected {
border-color: var(--gl-semantic-status-info);
background-color: var(--gl-neutral-button-hover);
}
/* Spans both text rows so the title/description stack beside it - the
title and description stay DIRECT label children (rather than sharing a
wrapper) so jsx-a11y's label-has-associated-control can still see the
label's own text at its default search depth. */
.builder-launch-choice input {
grid-row: span 2;
margin: 2px 0 0;
accent-color: var(--gl-semantic-status-info);
}
.builder-launch-choice-title { font-size: 13px; font-weight: 600; }
.builder-launch-choice-desc { font-size: 11px; color: var(--gl-surface-text-muted); }

.builder-launch-disclosure {
margin: 0;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
font-size: 11px;
line-height: 1.5;
color: var(--gl-surface-text-primary);
background-color: color-mix(in srgb, var(--gl-semantic-status-warning) 12%, transparent);
border-left: 2px solid var(--gl-semantic-status-warning);
border-radius: 4px;
}
.builder-launch-budgets { flex-direction: row; flex-wrap: wrap; gap: 12px; }
.builder-launch-budgets label {

.builder-launch-budgets { display: flex; gap: 10px; }
.builder-launch-budget {
display: flex;
flex-direction: column;
gap: 3px;
}
.builder-launch-budgets input { width: 110px; }
.builder-launch-disclosure {
font-size: 12px;
margin: 2px 0 0;
color: var(--gl-semantic-status-warning);
gap: 5px;
flex: 1;
min-width: 0;
}
.builder-launch-budget-label { font-size: 11px; color: var(--gl-surface-text-muted); }

.builder-launch-error { color: var(--gl-semantic-status-error); font-size: 12px; margin: 0; }
.builder-launch-actions { display: flex; justify-content: flex-end; }
.builder-launch-start {
font-size: 13px;
padding: 6px 14px;
font-family: inherit;
padding: 8px 16px;
border-radius: 6px;
border: 1px solid var(--gl-semantic-status-info);
background: var(--gl-neutral-button-background);
color: var(--gl-surface-text-primary);
cursor: pointer;
}
.builder-launch-start:hover { background: var(--gl-neutral-button-hover); }
.builder-launch-start:hover:not(:disabled) { background: var(--gl-neutral-button-hover); }
/* Disabled is the DEFAULT state here (no goal typed yet) and previously
looked identical to enabled - the primary action read as clickable
before it ever was. */
.builder-launch-start:disabled { opacity: 0.45; cursor: default; }
Loading