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
90 changes: 22 additions & 68 deletions apps/web/src/pages/new-workbench-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,16 @@
// posts the message so the worker starts.

import { toast } from "@corbits/react-ui/ui/toast";
import { Button, SelectionCheckbox } from "@corbits/react-ui";
import { WorkbenchLoadingState } from "@/chat";
import { Composer } from "@/chat/composer";
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { reportError } from "@corbits/error-sink";

import { useBench } from "../bench-context";
import { workbenchKeys } from "../chat-path";
import { NEW_WORKBENCH_TITLE, titleFromFirstMessage } from "@/auto-workbench-title";
import { useTenantRoles } from "../agent-roles-query";
import { listWorkbenchTenants, workbenchesQueryKey } from "@/chat/workbench-tenants";
import { createWorkbench, WorkbenchCreateError } from "../workbench-create";
import { useNavigate } from "../navigation";
import { ProviderSkipBanner } from "../provider-skip-banner";
Expand All @@ -29,27 +28,22 @@ export function describeWorkbenchCreateFailure(cause: unknown, refId?: string):
const message =
cause.stage === "deploy"
? "Workbench created, but its agent couldn't be deployed into it. Try again from the workbench."
: cause.stage === "roles"
? "Workbench created, but its agent's roles couldn't be assigned yet. Open the workbench so its agent starts, then assign roles in Settings."
: GENERIC_CREATE_FAILURE;
: GENERIC_CREATE_FAILURE;
return refId === undefined ? message : `${message} Reference: ${refId}`;
}

export function NewWorkbenchPickerRoute() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { selectedTenantId } = useBench();
// Role names, resolved against the new workbench's seeded system roles at
// create time — so only this workspace's system roles are offered here.
const roles = useTenantRoles(selectedTenantId);
const systemRoles = roles.kind === "ready" ? roles.data.filter((role) => role.isSystem) : [];
const [selectedRoles, setSelectedRoles] = useState<readonly string[]>([]);

function toggleRole(name: string) {
setSelectedRoles((current) =>
current.includes(name) ? current.filter((role) => role !== name) : [...current, name],
);
}
// Zero workbenches means this page doubles as the onboarding step that
// creates the first one — the title says so instead of the generic prompt.
const workbenches = useQuery({
queryKey: workbenchesQueryKey(selectedTenantId ?? ""),
enabled: selectedTenantId !== null,
queryFn: () => listWorkbenchTenants(selectedTenantId ?? ""),
});
const isFirstWorkbench = workbenches.data?.length === 0;

// Autofocus keeps the field quiet; the ring appears once Tab is used.
const [keyboard, setKeyboard] = useState(false);
Expand All @@ -65,17 +59,14 @@ export function NewWorkbenchPickerRoute() {
mutationFn: ({
benchTenantId,
openingMessage,
roles,
}: {
readonly benchTenantId: string;
readonly openingMessage: string;
readonly roles: readonly string[];
}) =>
createWorkbench({
benchTenantId,
name: titleFromFirstMessage(openingMessage) ?? NEW_WORKBENCH_TITLE,
openingMessage,
...(roles.length > 0 ? { roles } : {}),
}),
onSuccess: (tenantId, variables) => {
void queryClient.invalidateQueries({
Expand All @@ -101,14 +92,11 @@ export function NewWorkbenchPickerRoute() {
});

function submit(text: string) {
if (selectedTenantId === null || roles.kind !== "ready" || create.isPending) return;
create.mutate({ benchTenantId: selectedTenantId, openingMessage: text, roles: selectedRoles });
if (selectedTenantId === null || create.isPending) return;
create.mutate({ benchTenantId: selectedTenantId, openingMessage: text });
}

// The roles read gates the composer: submitting before the options load
// (or after they fail) would create the workbench without an informed
// choice, the silent fallback role assignment forbids.
const composerDisabled = selectedTenantId === null || roles.kind !== "ready";
const composerDisabled = selectedTenantId === null;

return (
<div className="page-frame">
Expand All @@ -120,48 +108,14 @@ export function NewWorkbenchPickerRoute() {
) : (
<>
<ProviderSkipBanner />
<h1 className="new-title">What should we work on?</h1>
<fieldset className="new-roles" disabled={create.isPending}>
<legend className="page-meta">
<span>Roles for this workbench&apos;s agent</span>
</legend>
{roles.kind === "loading" ? (
<p className="page-meta">Loading workspace roles…</p>
) : roles.kind === "ready" && systemRoles.length === 0 ? (
<p className="page-meta">No system roles in this workspace yet.</p>
) : roles.kind === "ready" ? (
<>
<p className="page-meta">
System roles apply to every workbench; custom roles stay in this workspace.
</p>
<ul className="new-roles-list">
{systemRoles.map((role) => (
<li key={role.id} className="inline-row">
<SelectionCheckbox
checked={selectedRoles.includes(role.name)}
onToggle={() => toggleRole(role.name)}
rowLabel={role.name}
ariaLabel={`Assign the ${role.name} role`}
/>
<span className="new-roles-name">{role.name}</span>
</li>
))}
</ul>
</>
) : roles.kind === "error" ? (
<p className="page-meta">
Couldn&apos;t load the workspace roles — creating a workbench is blocked until
they load.{" "}
<Button type="button" variant="outline" onClick={() => roles.retry()}>
Retry
</Button>
</p>
) : (
<p className="page-meta">
Sign in again to see this workspace&apos;s roles and start a workbench.
</p>
)}
</fieldset>
<h1 className="new-title">
{isFirstWorkbench ? "Create your first workbench" : "What should we work on?"}
</h1>
{isFirstWorkbench ? (
<p className="page-meta new-subtitle">
A workbench gives the job its own space and co-worker.
</p>
) : null}
<Composer
placeholder="Describe the job — your co-worker sets up the rest"
busy={false}
Expand Down
22 changes: 2 additions & 20 deletions apps/web/src/pages/new-workbench.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,7 @@
outline-offset: 2px;
}

.new-roles {
.new-subtitle {
margin: 0 0 16px;
padding: 0;
border: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}

.new-roles-list {
margin: 0.25rem 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.25rem;
}

.new-roles-name {
font-size: 0.875rem;
line-height: 1.25rem;
text-align: center;
}
34 changes: 2 additions & 32 deletions apps/web/src/workbench-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

import { isDefaultWorker } from "@/chat/threads-api";
import { agentSlugFromSourceAssetName, deployAgentSource } from "./agent-deploy";
import { assignRolesToAgent, resolveAgentRoleIds } from "./agent-roles";
import { readAgentSource } from "./agent-source-read";
import { deployWorkerSource } from "./worker-deploy";
import { parkOpeningMessage } from "./opening-message";
Expand All @@ -13,7 +12,7 @@ import { resolveExistingOffering } from "./onboarding/provider-connect-step";
export class WorkbenchCreateError extends Error {
constructor(
message: string,
readonly stage: "create" | "deploy" | "roles",
readonly stage: "create" | "deploy",
/** Set once the workbench tenant exists, so a later-stage failure can
* still land the person in the workbench it created. */
readonly tenantId?: string,
Expand Down Expand Up @@ -57,11 +56,6 @@ export type CreateWorkbenchInput = {
readonly name: string;
readonly assetName: string;
}[];
/** Role names checked in the create form, resolved against the new
* tenant's seeded system roles and assigned to the workbench's Worker.
* An unknown name fails before anything deploys; a Worker that hasn't
* run yet (no principal) fails after, with the workbench in place. */
readonly roles?: readonly string[];
};

/** Returns the new workbench's tenant id — its deep link is `/w/<id>`. */
Expand Down Expand Up @@ -92,16 +86,7 @@ export async function createWorkbench(input: CreateWorkbenchInput): Promise<stri
if (offering === null) {
throw new Error("Connect a model provider in Settings before starting a workbench.");
}
// Resolve the selection against the new tenant's seeded system roles
// before deploying: an unknown name fails here, with the workbench in
// place but nothing deployed into it yet.
let roleIds: readonly string[] = [];
try {
roleIds = await resolveAgentRoleIds(tenantId, input.roles ?? []);
} catch (cause) {
throw failure(cause, "roles", tenantId);
}
const worker = await deployWorkerSource({
await deployWorkerSource({
tenantId,
tenantDomain: domain,
sourceOfferingIds: offering.sourceOfferingIds,
Expand All @@ -123,21 +108,6 @@ export async function createWorkbench(input: CreateWorkbenchInput): Promise<stri
input: { name: picked.name, systemPrompt: source.systemPrompt, slug },
});
}

// Worker's workflow principal only exists after its first run, so a
// fresh workbench fails here with the workbench in place — opening it
// starts the worker, and the roles apply on a later attempt.
if (roleIds.length > 0) {
try {
await assignRolesToAgent({
tenantId,
roleIds,
principalRef: { deploymentId: worker.deploymentId, address: `worker@${domain}` },
});
} catch (cause) {
throw failure(cause, "roles", tenantId);
}
}
} catch (cause) {
if (cause instanceof WorkbenchCreateError) throw cause;
throw failure(cause, "deploy", tenantId);
Expand Down
49 changes: 49 additions & 0 deletions e2e/browser/new-workbench-first-run.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// CL-9844: a fresh bench lands on /new as a first-workbench onboarding step
// with no roles picker gating the composer.
import { expect, test } from "bun:test";
import { bootBrowserApp, browserGate } from "../lib/browser";
import { STEP_TIMEOUT, waitForText } from "../lib/first-run";

const describeBrowser = browserGate(import.meta.path);

describeBrowser("new workbench first run", () => {
const app = bootBrowserApp();

test("zero workbenches get onboarding copy and an ungated composer", async () => {
const { page } = await app().newPage();
try {
await page.goto(app().origin, { waitUntil: "networkidle0" });
await page.waitForSelector(".auth-switch", { timeout: 30_000 });
await page.click(".auth-switch");
await page.type(
"input:not([type=password]):not([type=hidden])",
`firstrun-${String(Date.now())}@example.com`,
);
await page.type("input[type=password]", "correct-horse-battery-staple");
await page.click("button[type=submit]");

await page.waitForFunction("location.pathname === '/onboarding'", { timeout: 30_000 });
await page.waitForSelector("button::-p-text(Skip for now)", { timeout: 30_000 });
// A synthetic click: the step's buttons animate and pointer clicks miss.
await page.evaluate(
"Array.from(document.querySelectorAll('button')).find((b) => b.innerText.includes('Skip for now')).click()",
);
await page.waitForFunction("location.pathname === '/new'", { timeout: 30_000 });

// First-run framing, not the generic empty-shell prompt.
await waitForText(page, "Create your first workbench");

// No roles picker: creating a workbench deploys its agent already.
const body = (await page.evaluate("document.body.innerText")) as string;
expect(body.includes("Roles for this workbench")).toBe(false);

// The composer is usable without waiting on a roles read.
await page.waitForSelector("textarea:not([disabled])", { timeout: STEP_TIMEOUT });
} catch (cause) {
const body = await page.evaluate("document.body.innerText");
process.stderr.write(`new-workbench-first-run failed at ${page.url()}:\n${String(body)}\n`);
throw cause;
}
await page.close();
}, 120_000);
});
Loading