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
11 changes: 11 additions & 0 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -3388,6 +3388,17 @@ tr.insights-row-clickable:hover {
color: var(--muted-foreground);
}

.new-workbench-picker-not-ready {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}

.new-workbench-picker-not-ready .new-workbench-picker-sub {
margin: 0;
}

.new-workbench-pick-list {
border: 1px solid var(--border);
}
Expand Down
32 changes: 30 additions & 2 deletions apps/web/src/instant-agent-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,34 @@ export const NEW_WORKBENCH_TITLE = "New Workbench";
* that error type's own describer instead — allow-listing safe
* throws, rather than denylisting unsafe ones, so a new error type
* added later fails safe (masked) instead of leaking by default.
*
* `kind` lets a caller tell "the setup agent isn't deployed yet" apart
* from "this template genuinely doesn't exist here" without parsing
* `message` text: the first is very often a still-provisioning bench
* (CL-6457's background deploy hasn't finished, or never started
* without a credential) that the caller should check
* `fetchAgentReadiness` over before treating as a dead end; the second
* never resolves itself and should surface as-is.
*/
export class WorkbenchPreconditionError extends Error {}
export class WorkbenchPreconditionError extends Error {
readonly kind: "setup-agent-missing" | "template-unavailable";
constructor(
message: string,
kind: "setup-agent-missing" | "template-unavailable",
) {
super(message);
this.kind = kind;
}
}

/**
* Consumer-language stand-in for the system precondition this bench
* hit: "no deployed setup agent" describes an internal implementation
* detail, never something a person signing in for the first time
* should have to parse.
*/
const SETUP_AGENT_MISSING_MESSAGE =
"Your workbench is still finishing setup. Try again in a moment.";

/**
* Presents the connected org's repo list for the person to pick from once
Expand Down Expand Up @@ -94,7 +120,8 @@ export async function createWorkbenchFromTemplate(
const setupTemplate = findMyraDefinition(definitions);
if (setupTemplate === undefined) {
throw new WorkbenchPreconditionError(
"No default setup agent found for this workbench.",
SETUP_AGENT_MISSING_MESSAGE,
"setup-agent-missing",
);
}
// The manifest comes from the bench library (CL-6344), never from a
Expand All @@ -112,6 +139,7 @@ export async function createWorkbenchFromTemplate(
if (templateId !== "blank" && manifest === undefined) {
throw new WorkbenchPreconditionError(
`A ${templateId} workbench isn't available here yet.`,
"template-unavailable",
);
}
const requiresGithub =
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/pages/new-workbench-picker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe("describeWorkbenchCreateFailure", () => {
describeWorkbenchCreateFailure(
new WorkbenchPreconditionError(
"A code-review workbench isn't available here yet.",
"template-unavailable",
),
),
).toBe("A code-review workbench isn't available here yet.");
Expand Down
42 changes: 41 additions & 1 deletion apps/web/src/pages/new-workbench-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
WorkbenchPreconditionError,
type PickGithubRepos,
} from "../instant-agent-create";
import { fetchAgentReadiness } from "../onboarding";
import { useNavigate } from "../navigation";
import { StageTopBar } from "../shell/stage-top-bar";
import {
Expand Down Expand Up @@ -96,6 +97,13 @@ export function NewWorkbenchPickerRoute() {
);
const [picked, setPicked] = useState<WorkbenchTemplateId | null>(null);
const [creating, setCreating] = useState(false);
// Set only when `createWorkbenchFromTemplate` hit the missing-setup-agent
// precondition *and* a readiness check confirmed the bench genuinely
// isn't chat-ready yet — never a guess from the error alone, since that
// precondition is also what a template-that-will-never-exist looks like.
// Distinct from `creating`'s loader: this is a dead end until setup
// finishes, not a request in flight.
const [stillSettingUp, setStillSettingUp] = useState(false);
const [repoPicker, setRepoPicker] = useState<RepoPickerState | null>(null);

// What this bench's library can actually serve (CL-6458). A kind whose
Expand Down Expand Up @@ -127,6 +135,7 @@ export function NewWorkbenchPickerRoute() {
async function handleCreate() {
if (selectedTenantId === null || creating) return;
setCreating(true);
setStillSettingUp(false);
try {
await createWorkbenchFromTemplate(
selectedTenantId,
Expand All @@ -135,6 +144,22 @@ export function NewWorkbenchPickerRoute() {
pickGithubRepos,
);
} catch (cause) {
// The missing-setup-agent precondition reads identically whether
// this bench's default agents never finished deploying (CL-6457's
// background drain is still running, or never started without a
// credential) or something is genuinely broken. Only a readiness
// check tells those apart — never assume from the throw alone.
if (
cause instanceof WorkbenchPreconditionError &&
cause.kind === "setup-agent-missing"
) {
const readiness = await fetchAgentReadiness();
if (readiness.kind !== "ready" && readiness.kind !== "chat-ready") {
setCreating(false);
setStillSettingUp(true);
return;
}
}
log.error("Couldn't create the workbench", {
message: cause instanceof Error ? cause.message : String(cause),
status:
Expand Down Expand Up @@ -164,7 +189,22 @@ export function NewWorkbenchPickerRoute() {
}
/>
<div className="new-workbench-picker">
{creating ? (
{stillSettingUp ? (
<div className="new-workbench-picker-not-ready">
<h3>Still setting up your workbench</h3>
<p className="new-workbench-picker-sub">
Your account&apos;s agents are finishing setup in the background.
This usually takes under a minute — try again in a moment.
</p>
<Button
type="button"
variant="outline"
onClick={() => void handleCreate()}
>
Try again
</Button>
</div>
) : creating ? (
<WorkbenchLoadingState title="Setting up your workbench…" />
) : library.kind === "loading" ? (
<WorkbenchLoadingState title="Seeing what you can set up here…" />
Expand Down
41 changes: 41 additions & 0 deletions apps/web/test/new-workbench-picker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,47 @@ describe("NewWorkbenchPickerRoute", () => {
expect(codeReview?.getAttribute("aria-checked")).toBe("false");
});

// CL-6510: a bench whose default agents haven't finished deploying yet
// (CL-6457's background drain still running, or never started without a
// credential) must never dead-end the person on the raw internal
// precondition message — the picker checks readiness first and shows an
// honest, retryable "still setting up" state instead.
test("when the setup agent isn't deployed yet, creating shows an honest still-setting-up state, not the raw precondition error", async () => {
stubFetch((path) => {
if (path.includes("/workflows/definitions")) {
return json({ data: [], nextCursor: null });
}
if (path.endsWith("/api/onboarding/provisioning-status")) {
return json({
kind: "provisioning",
tenantId: "tnt_1",
tenantSlug: "corbits-bench",
setupAgentReady: false,
deployed: [],
pending: ["assistant"],
});
}
return undefined;
});
await renderPicker();

const createButton = Array.from(
container?.querySelectorAll("button") ?? [],
).find((button) => button.textContent === "Create workbench");
await act(async () => {
createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
for (let i = 0; i < 20; i++) {
await settle();
if (container?.textContent?.includes("Still setting up")) break;
}

expect(container?.textContent).toContain("Still setting up your workbench");
expect(container?.textContent).not.toContain(
"No default setup agent found",
);
});

test("creating with Code review selected mints a workbench from the template, then navigates in", async () => {
const createdAgentHandles: string[] = [];
const calls = stubFetch((path, init) => {
Expand Down
77 changes: 72 additions & 5 deletions apps/web/test/toast-single-system.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,37 @@ const MEMBERSHIP = {
nextCursor: null,
};

// A genuine create failure: the tenant has its setup agent deployed (so
// the flow gets past `findMyraDefinition`'s precondition), and the actual
// workbench-create request is what 500s. Serving an empty definitions
// list here instead would fail the precondition first, which is a
// different, already-covered path (a missing setup agent shows the
// retry panel below, not a toast) — this stub exists to prove the
// one-toast invariant for a real create failure, so it must reach one.
function stubFailingCreate(): void {
globalThis.fetch = ((input: RequestInfo | URL) => {
const path = typeof input === "string" ? input : String(input);
if (path.includes("/api/me/principals")) {
return Promise.resolve(json(MEMBERSHIP));
}
if (path.includes("/workflows/definitions")) {
return Promise.resolve(json({ data: [], nextCursor: null }));
return Promise.resolve(
json({
data: [
{
id: "def-assistant",
tenantId: "tnt_1",
name: "assistant",
currentVersion: "1",
status: "deployed",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
skills: [] as readonly string[],
},
],
nextCursor: null,
}),
);
}
return Promise.resolve(json({ error: "boom" }, 500));
}) as typeof fetch;
Expand Down Expand Up @@ -142,15 +165,59 @@ describe("the one toast system (CL-6372)", () => {

const shown = visibleToasts();
expect(shown.length).toBe(1);
// The stub serves an empty definitions list, so the create fails its
// precondition before any request is sent. That is a
// `WorkbenchPreconditionError`, which the picker shows verbatim.
// The stub's setup agent is deployed, so this is a real create
// failure (the workbench-create request itself 500s) — a
// `ChatApiError`, described through `describeChatError`.
expect(shown[0]?.textContent).toBe(
"No default setup agent found for this workbench.",
"Something went wrong on our end. Try again in a moment.",
);
await waitForClear();
});

// CL-6510: the new contract this file's own change introduced — a
// missing setup agent no longer fires a toast at all, since the
// picker now shows a retryable "still setting up" panel instead of
// treating that precondition as a dead end.
test("a missing setup agent shows the retry panel and fires no toast", async () => {
globalThis.fetch = ((input: RequestInfo | URL) => {
const path = typeof input === "string" ? input : String(input);
if (path.includes("/api/me/principals")) {
return Promise.resolve(json(MEMBERSHIP));
}
if (path.includes("/workflows/definitions")) {
return Promise.resolve(json({ data: [], nextCursor: null }));
}
if (path.endsWith("/api/onboarding/provisioning-status")) {
return Promise.resolve(
json({
kind: "provisioning",
tenantId: "tnt_1",
tenantSlug: "corbits-bench",
setupAgentReady: false,
deployed: [],
pending: ["assistant"],
}),
);
}
return Promise.resolve(json({ error: "boom" }, 500));
}) as typeof fetch;
await renderPickerWithToaster();

const createButton = Array.from(
container?.querySelectorAll("button") ?? [],
).find((button) => button.textContent === "Create workbench");
await act(async () => {
createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
for (let i = 0; i < 30; i++) {
await settle();
if (container?.textContent?.includes("Still setting up")) break;
}

expect(container?.textContent).toContain("Still setting up your workbench");
expect(visibleToasts().length).toBe(0);
});

test("the failure toast carries the house styling, not sonner's default", async () => {
stubFailingCreate();
await renderPickerWithToaster();
Expand Down
Loading
Loading