diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 84fe4767f2..2fd9883ad7 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -3356,21 +3356,23 @@ tr.insights-row-clickable:hover { selection rail (`box-shadow: inset 2px 0 0 0 var(--shell-accent)`) matches the sidebar's own active-row treatment exactly. */ .new-workbench-picker { - max-width: 40rem; + width: min(60rem, 100%); margin: 0 auto; - padding: 3.25rem 1.5rem 2.5rem; + padding: 3.5rem 1.5rem 3rem; } .new-workbench-picker h3 { margin: 0; - font-size: 1.375rem; + font-size: 2.5rem; font-weight: 700; - letter-spacing: -0.02em; + letter-spacing: -0.035em; + line-height: 1.15; } .new-workbench-picker-sub { - margin: 0.3rem 0 1.5rem; - font-size: 0.875rem; + margin: 0.5rem 0 1.75rem; + font-size: 1rem; + line-height: 1.5; color: var(--muted-foreground); } @@ -3390,13 +3392,15 @@ tr.insights-row-clickable:hover { send button inline, sized to read as "type here first". */ .new-workbench-prompt { display: flex; - align-items: flex-end; - gap: 0.6rem; + flex-direction: column; + align-items: stretch; border: 1px solid var(--border-strong, var(--border)); - border-radius: 0.6rem; - padding: 0.7rem 0.7rem 0.7rem 1rem; - margin-bottom: 1.5rem; + border-radius: 0.875rem; + margin-bottom: 2.5rem; background: var(--background); + box-shadow: + 0 1px 2px rgb(43 38 39 / 5%), + 0 20px 40px -24px rgb(43 38 39 / 22%); } .new-workbench-prompt:has(.new-workbench-prompt-input:focus) { @@ -3405,43 +3409,230 @@ tr.insights-row-clickable:hover { } .new-workbench-prompt-input { - flex: 1; + width: 100%; min-width: 0; resize: none; border: 0; background: transparent; + caret-color: var(--shell-accent); font: inherit; - font-size: 0.9375rem; - line-height: 1.4; - padding: 0.2rem 0; + font-size: 1.25rem; + line-height: 1.45; + padding: 0; } .new-workbench-prompt-input:focus { outline: none; } -.new-workbench-prefab-grid { +.new-workbench-prompt-main { + display: flex; + flex-direction: column; + gap: 1.25rem; + min-height: 10.5rem; + padding: 1.375rem 1.5rem 1rem; +} + +.new-workbench-prompt-examples, +.new-workbench-agent-selection, +.new-workbench-prompt-actions { + display: flex; + align-items: center; + flex-wrap: wrap; +} + +.new-workbench-prompt-examples { + gap: 0.5rem; + font-size: 0.8125rem; + color: var(--muted-foreground); +} + +.new-workbench-prompt-examples button, +.new-workbench-add-agent { + border: 1px solid var(--border); + border-radius: 999px; + background: var(--muted); + color: var(--foreground); + font: inherit; + font-size: 0.8125rem; + line-height: 1rem; + padding: 0.375rem 0.75rem; + cursor: pointer; +} + +.new-workbench-prompt-examples button:hover { + border-color: var(--border-strong, var(--border)); + background: color-mix(in srgb, var(--muted) 72%, var(--foreground)); +} + +.new-workbench-add-agent:hover, +.new-workbench-add-agent[aria-expanded="true"] { + border-color: var(--border-strong, var(--border)); + background: var(--background); +} + +.new-workbench-prompt-examples button.is-selected, +.new-workbench-prompt-examples button.is-selected:hover { + border-color: var(--foreground); + background: var(--foreground); + color: var(--background); +} + +.new-workbench-prompt-actions { + justify-content: space-between; + gap: 1rem; + border-top: 1px solid var(--border); + padding: 0.75rem; +} + +.new-workbench-agent-selection { + position: relative; + flex: 1 1 30rem; + gap: 0.5rem; + min-width: 0; + font-size: 0.8125rem; + font-weight: 600; + color: var(--muted-foreground); +} + +.new-workbench-agent-chip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + border-radius: 999px; + background: var(--foreground); + color: var(--background); + padding: 0.3rem 0.45rem 0.3rem 0.625rem; +} + +.new-workbench-agent-chip button { + width: 1rem; + height: 1rem; + border: 0; + border-radius: 50%; + background: transparent; + color: inherit; + font: inherit; + font-size: 1rem; + line-height: 1; + cursor: pointer; +} + +.new-workbench-agent-status { + font-weight: 400; +} + +.new-workbench-agent-picker { + position: relative; +} + +.new-workbench-add-agent { + border-style: dashed; + background: var(--background); + font-weight: 600; +} + +.new-workbench-agent-popover { + position: absolute; + z-index: 2; + top: calc(100% + 0.5rem); + left: 0; + width: min(21.25rem, calc(100vw - 3rem)); + border: 1px solid var(--border); + border-radius: 0.75rem; + background: var(--background); + box-shadow: 0 20px 40px -16px rgb(43 38 39 / 28%); + padding: 0.5rem; +} + +.new-workbench-agent-search { + width: 100%; + border: 0; + border-bottom: 1px solid var(--border); + background: transparent; + color: var(--foreground); + font: inherit; + font-size: 0.875rem; + padding: 0.5rem; +} + +.new-workbench-agent-search:focus { + outline: none; +} + +.new-workbench-agent-option { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0.75rem; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + width: 100%; + gap: 0.5rem; + border: 0; + border-radius: 0.5rem; + background: transparent; + color: var(--foreground); + font: inherit; + text-align: left; + padding: 0.625rem; + cursor: pointer; } -@media (max-width: 640px) { - .new-workbench-prefab-grid { - grid-template-columns: 1fr; - } +.new-workbench-agent-option.is-active, +.new-workbench-agent-option:hover { + background: var(--muted); } -.new-workbench-prefab-card { +.new-workbench-agent-option-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.875rem; + font-weight: 600; +} + +.new-workbench-agent-option-handle, +.new-workbench-agent-empty { + margin: 0; + color: var(--muted-foreground); + font-size: 0.75rem; + font-weight: 400; +} + +.new-workbench-agent-empty { + padding: 0.75rem 0.5rem; +} + +.new-workbench-prefab-grid { display: flex; flex-direction: column; - align-items: flex-start; - gap: 0.6rem; + gap: 0.5rem; +} + +.new-workbench-template-heading { + display: flex; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.625rem; + padding: 0 0.25rem; + color: var(--muted-foreground); + font-size: 0.8125rem; +} + +.new-workbench-template-heading span:first-child { + color: var(--foreground); + font-weight: 650; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.new-workbench-prefab-card { + display: flex; + align-items: center; + gap: 0.875rem; text-align: left; border: 1px solid var(--border); - border-radius: 0.5rem; + border-radius: 0.625rem; background: var(--background); - padding: 0.9rem; + padding: 0.75rem 0.875rem; font: inherit; cursor: pointer; } @@ -3462,8 +3653,8 @@ tr.insights-row-clickable:hover { } .new-workbench-pick-glyph { - width: 2.1rem; - height: 2.1rem; + width: 2.125rem; + height: 2.125rem; flex-shrink: 0; display: grid; place-items: center; @@ -3472,6 +3663,7 @@ tr.insights-row-clickable:hover { } .new-workbench-pick-text { + flex: 1; min-width: 0; } @@ -3484,11 +3676,56 @@ tr.insights-row-clickable:hover { .new-workbench-pick-promise { display: block; - font-size: 0.8125rem; + font-size: 0.875rem; color: var(--muted-foreground); margin-top: 0.1rem; } +.new-workbench-empty-channel { + border: 0; + background: transparent; + color: var(--muted-foreground); + font: inherit; + font-size: 0.875rem; + margin-top: 1rem; + padding: 0.25rem; + text-align: left; + cursor: pointer; +} + +.new-workbench-empty-channel span { + color: var(--foreground); + font-weight: 600; + text-decoration: underline; + text-underline-offset: 0.2rem; +} + +.new-workbench-empty-channel:disabled, +.new-workbench-add-agent:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +@media (max-width: 640px) { + .new-workbench-picker { + padding-top: 2rem; + } + + .new-workbench-picker h3 { + font-size: 2rem; + } + + .new-workbench-prompt-actions, + .new-workbench-template-heading { + align-items: flex-start; + flex-direction: column; + } + + .new-workbench-agent-selection { + flex-basis: auto; + } +} + /* Mission Control (CL-6488/CL-6489) — stat strip, then a main column (Needs you / In flight) beside a 320px rail (Jump back in / This week), the same "stack under 1100px" rule DESIGN.md gives every right rail. */ diff --git a/apps/web/src/instant-agent-create.test.ts b/apps/web/src/instant-agent-create.test.ts index 8d2044478f..0b8918d65f 100644 --- a/apps/web/src/instant-agent-create.test.ts +++ b/apps/web/src/instant-agent-create.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { QueryClient } from "@tanstack/react-query"; import { CODE_REVIEW_TEMPLATE, @@ -8,6 +8,7 @@ import { import { createWorkbenchFromTemplate, NEW_WORKBENCH_TITLE, + WorkbenchPostCreateError, } from "./instant-agent-create"; function newQueryClient(): QueryClient { @@ -146,6 +147,172 @@ describe("createWorkbenchFromTemplate", () => { expect(calls.some((call) => call.path.includes("/invite"))).toBe(false); }); + test("a blank intent carries selected agents in its pre-invite message", async () => { + const calls = stubFetch((path) => { + if (path.includes("/workflows/definitions")) { + return json({ data: [assistantDefinitionWire], nextCursor: null }); + } + if (path.endsWith("/chat/workbenches")) { + return json({ + id: "chan-1", + title: NEW_WORKBENCH_TITLE, + kind: "workbench", + pinned: false, + participants: [], + }); + } + if (path.endsWith("/chat/workbenches/chan-1/messages")) { + return json({ id: "msg-1", createdAt: "2026-01-01T00:00:00.000Z" }); + } + if (path.endsWith("/chat/workbenches/chan-1/settings")) { + return json({ + id: "chan-1", + title: "Research our next partner", + kind: "workbench", + pinned: false, + participants: [], + settings: {}, + contextWindow: { value: 0, source: "inherit" }, + }); + } + throw new Error(`unexpected fetch: ${path}`); + }); + + await createWorkbenchFromTemplate( + "tnt_1", + "blank", + () => undefined, + newQueryClient(), + "Research our next partner", + ["def-scout", "def-scout", "def-quill"], + ); + + const createCallIndex = calls.findIndex((call) => + call.path.endsWith("/chat/workbenches"), + ); + const messageCallIndex = calls.findIndex((call) => + call.path.endsWith("/chat/workbenches/chan-1/messages"), + ); + const messageCall = calls[messageCallIndex]; + expect(createCallIndex).toBeLessThan(messageCallIndex); + expect(JSON.parse(String(messageCall?.init?.body))).toEqual({ + parts: [{ kind: "text", text: "Research our next partner" }], + invite: [ + { kind: "agent", definitionId: "def-scout" }, + { kind: "agent", definitionId: "def-quill" }, + ], + }); + expect(calls.some((call) => call.path.endsWith("/invite"))).toBe(false); + }); + + test("opens the new room while its sidebar refresh is still in flight", async () => { + const navigated: string[] = []; + let finishRefresh: (() => void) | undefined; + const refresh = new Promise((resolve) => { + finishRefresh = resolve; + }); + const queryClient = newQueryClient(); + const invalidate = spyOn( + queryClient, + "invalidateQueries", + ).mockImplementation(() => refresh); + stubFetch((path) => { + if (path.includes("/workflows/definitions")) { + return json({ data: [assistantDefinitionWire], nextCursor: null }); + } + if (path.endsWith("/chat/workbenches")) { + return json({ + id: "chan-ready-now", + title: NEW_WORKBENCH_TITLE, + kind: "workbench", + pinned: false, + participants: [], + }); + } + if (path.endsWith("/chat/workbenches/chan-ready-now/messages")) { + return json({ id: "msg-1", createdAt: "2026-01-01T00:00:00.000Z" }); + } + if (path.endsWith("/chat/workbenches/chan-ready-now/settings")) { + return json({ + id: "chan-ready-now", + title: "Ship the release", + kind: "workbench", + pinned: false, + participants: [], + settings: {}, + contextWindow: { value: 0, source: "inherit" }, + }); + } + throw new Error(`unexpected fetch: ${path}`); + }); + + try { + const completed = await Promise.race([ + createWorkbenchFromTemplate( + "tnt_1", + "blank", + (to) => navigated.push(to), + queryClient, + "Ship the release", + ["def-assistant"], + ).then(() => "completed"), + new Promise<"timed out">((resolve) => { + setTimeout(() => resolve("timed out"), 50); + }), + ]); + + expect(completed).toBe("completed"); + expect(navigated).toEqual(["/w/chan-ready-now"]); + } finally { + finishRefresh?.(); + invalidate.mockRestore(); + } + }); + + test("an opening-message failure leaves a minted blank workbench recoverable", async () => { + const navigated: string[] = []; + stubFetch((path) => { + if (path.includes("/workflows/definitions")) { + return json({ data: [assistantDefinitionWire], nextCursor: null }); + } + if (path.endsWith("/chat/workbenches")) { + return json({ + id: "chan-recoverable", + title: NEW_WORKBENCH_TITLE, + kind: "workbench", + pinned: false, + participants: [], + }); + } + if (path.endsWith("/chat/workbenches/chan-recoverable/messages")) { + return json({ error: "agent launch failed" }, 409); + } + throw new Error(`unexpected fetch: ${path}`); + }); + + let cause: unknown; + try { + await createWorkbenchFromTemplate( + "tnt_1", + "blank", + (to) => navigated.push(to), + newQueryClient(), + "Research our next partner", + ["def-scout"], + ); + } catch (error) { + cause = error; + } + + expect(cause).toBeInstanceOf(WorkbenchPostCreateError); + if (!(cause instanceof WorkbenchPostCreateError)) { + throw new Error("expected a recoverable post-create error"); + } + expect(cause.workbenchId).toBe("chan-recoverable"); + expect(cause.stage).toBe("opening-message"); + expect(navigated).toEqual([]); + }); + test("picking the code-review definition names the bench after it and invites exactly its three reviewers", async () => { const navigated: string[] = []; let nextReviewerId = 0; diff --git a/apps/web/src/instant-agent-create.ts b/apps/web/src/instant-agent-create.ts index c6152d07fd..054bcc71a5 100644 --- a/apps/web/src/instant-agent-create.ts +++ b/apps/web/src/instant-agent-create.ts @@ -26,6 +26,7 @@ import { type WorkbenchDefinition, type WorkbenchOnboardingStep, } from "@workbench/templates"; +import { reportError } from "@corbits/error-sink"; import { deployWorkbenchTemplateBlock, @@ -44,6 +45,24 @@ import type { WorkbenchTemplateId } from "./workbench-templates"; export { NEW_WORKBENCH_TITLE }; +export function refreshWorkbenchLists( + queryClient: QueryClient, + tenantId: string, + workbenchId: string, +): void { + void queryClient + .invalidateQueries({ + queryKey: workbenchesQueryKeyPrefix(tenantId), + }) + .catch((cause) => { + reportError(cause, { + operation: "refresh_workbench_lists", + tenantId, + roomId: workbenchId, + }); + }); +} + /** * Marks the two precondition failures below as intentionally * user-facing: their `message` is authored copy, never a raw request @@ -73,6 +92,16 @@ export class WorkbenchPreconditionError extends Error { } } +export class WorkbenchPostCreateError extends Error { + constructor( + readonly workbenchId: string, + readonly stage: "opening-message" | "rename", + cause: unknown, + ) { + super("The workbench was created, but setup did not finish.", { cause }); + } +} + /** * Consumer-language stand-in for the system precondition this bench * hit: "no deployed setup agent" describes an internal implementation @@ -156,7 +185,11 @@ export async function createWorkbenchFromTemplate( navigate: (to: string) => void, queryClient: QueryClient, firstMessage?: string, + selectedAgentDefinitionIds: readonly string[] = [], ): Promise { + if (templateId !== "blank" && selectedAgentDefinitionIds.length > 0) { + throw new Error("Only a new blank workbench can select agents directly."); + } const definitions = await listAgentDefinitions(tenantId); const setupTemplate = findMyraDefinition(definitions); if (setupTemplate === undefined) { @@ -223,27 +256,53 @@ export async function createWorkbenchFromTemplate( ); }, }); - await queryClient.invalidateQueries({ - queryKey: workbenchesQueryKeyPrefix(tenantId), - }); } if (firstMessage !== undefined && firstMessage.trim() !== "") { - await sendMessage(tenantId, workbench.id, partsForSend(firstMessage, [])); + const selectedAgentInvites = [...new Set(selectedAgentDefinitionIds)].map( + (definitionId) => ({ kind: "agent" as const, definitionId }), + ); + try { + await sendMessage( + tenantId, + workbench.id, + partsForSend(firstMessage, []), + { + ...(selectedAgentInvites.length > 0 + ? { invite: selectedAgentInvites } + : {}), + }, + ); + } catch (cause) { + if (templateId === "blank") { + throw new WorkbenchPostCreateError( + workbench.id, + "opening-message", + cause, + ); + } + throw cause; + } // Blank / ad-hoc mints stay "New Workbench" until named. When the // prompt box already supplied the opening message, rename via the same // `chat/name` settings PATCH the sidebar rename uses — prefab titles // (`definition?.title`) are left alone by `autoNameFromFirstMessage`. const autoTitle = autoNameFromFirstMessage(workbench.title, firstMessage); if (autoTitle !== undefined) { - await patchWorkbenchSettings(tenantId, workbench.id, { - "chat/name": autoTitle, - }); - await queryClient.invalidateQueries({ - queryKey: workbenchesQueryKeyPrefix(tenantId), - }); + try { + await patchWorkbenchSettings(tenantId, workbench.id, { + "chat/name": autoTitle, + }); + } catch (cause) { + if (templateId === "blank") { + throw new WorkbenchPostCreateError(workbench.id, "rename", cause); + } + throw cause; + } + refreshWorkbenchLists(queryClient, tenantId, workbench.id); } } + refreshWorkbenchLists(queryClient, tenantId, workbench.id); navigate(workbenchPath(workbench.id)); } diff --git a/apps/web/src/pages/new-workbench-picker.test.ts b/apps/web/src/pages/new-workbench-picker.test.ts index bfae8323fb..5217c1374d 100644 --- a/apps/web/src/pages/new-workbench-picker.test.ts +++ b/apps/web/src/pages/new-workbench-picker.test.ts @@ -15,11 +15,29 @@ import { ApiQueryError } from "@corbits/api-query"; import { ChatApiError } from "@corbits/chat-ui"; import { describeWorkbenchCreateFailure } from "./new-workbench-picker"; -import { WorkbenchPreconditionError } from "../instant-agent-create"; +import { + WorkbenchPostCreateError, + WorkbenchPreconditionError, +} from "../instant-agent-create"; const GENERIC = "Something went wrong creating this workbench. Try again."; describe("describeWorkbenchCreateFailure", () => { + test("a recoverable post-create failure gives the person its support reference", () => { + expect( + describeWorkbenchCreateFailure( + new WorkbenchPostCreateError( + "chan_1", + "opening-message", + new Error("agent launch failed"), + ), + "ref_1", + ), + ).toBe( + "Workbench created, but we couldn't send the opening message or add the selected agent. Try again from the room. Reference: ref_1", + ); + }); + test("a WorkbenchPreconditionError (no setup agent, unavailable template) is shown verbatim", () => { expect( describeWorkbenchCreateFailure( diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx index a0e62a5ed1..a22fc70133 100644 --- a/apps/web/src/pages/new-workbench-picker.tsx +++ b/apps/web/src/pages/new-workbench-picker.tsx @@ -9,6 +9,7 @@ // second step anywhere on this screen. import { Button, toast } from "@corbits/react-ui"; +import { useDismissablePopover } from "@corbits/react-ui/hooks/use-dismissable-popover"; import { ChatCircle, GitPullRequest, @@ -19,30 +20,33 @@ import { ChatApiError, CHAT_STRINGS, describeChatError, + listTenantInvitableDefinitions, WorkbenchLoadingState, } from "@corbits/chat-ui"; +import { humanizeSlug } from "@corbits/chat/display-name"; import { useEffect, useRef, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; -import { getLogger } from "@corbits/client-log"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ApiQueryError, describeApiError } from "@corbits/api-query"; +import { reportError } from "@corbits/error-sink"; import { useAPIQuery } from "../api"; import { TemplateLibraryPage } from "../workbench-templates-api"; import { useBench } from "../bench-context"; import { createWorkbenchFromTemplate, + refreshWorkbenchLists, + WorkbenchPostCreateError, WorkbenchPreconditionError, } from "../instant-agent-create"; import { fetchAgentReadiness } from "../onboarding"; import { useNavigate } from "../navigation"; import { StageTopBar } from "../shell/stage-top-bar"; +import { workbenchPath } from "../workbench-path"; import { WORKBENCH_TEMPLATES, type WorkbenchTemplateId, } from "../workbench-templates"; -const log = getLogger("web.new-workbench-picker"); - const GENERIC_CREATE_FAILURE = "Something went wrong creating this workbench. Try again."; @@ -57,7 +61,17 @@ const GENERIC_CREATE_FAILURE = * request paths and schema summaries in `.message` and must go through * their own describer, never shown directly. */ -export function describeWorkbenchCreateFailure(cause: unknown): string { +export function describeWorkbenchCreateFailure( + cause: unknown, + refId?: string, +): string { + if (cause instanceof WorkbenchPostCreateError) { + const message = + cause.stage === "opening-message" + ? "Workbench created, but we couldn't send the opening message or add the selected agent. Try again from the room." + : "Workbench created, but we couldn't rename it."; + return refId === undefined ? message : `${message} Reference: ${refId}`; + } if (cause instanceof WorkbenchPreconditionError) return cause.message; if (cause instanceof ApiQueryError) { return describeApiError(cause, "creating this workbench"); @@ -79,6 +93,17 @@ const CARD_ICON: Record = { const BLANK_TEMPLATE_ID: WorkbenchTemplateId = "blank"; const PROMPT_PLACEHOLDER = "What do you want your Workbench to do?"; +const AGENT_LISTBOX_ID = "new-workbench-agent-listbox"; + +function agentDisplayName({ + name, + description, +}: { + readonly name: string; + readonly description?: string; +}): string { + return description ?? humanizeSlug(name); +} export function NewWorkbenchPickerRoute() { const navigate = useNavigate(); @@ -91,6 +116,12 @@ export function NewWorkbenchPickerRoute() { TemplateLibraryPage, ); const [prompt, setPrompt] = useState(""); + const [selectedAgentDefinitionIds, setSelectedAgentDefinitionIds] = useState< + readonly string[] + >([]); + const [agentPickerOpen, setAgentPickerOpen] = useState(false); + const [agentQuery, setAgentQuery] = useState(""); + const [activeAgentIndex, setActiveAgentIndex] = useState(0); const [creating, setCreating] = useState(false); // Set only when `createWorkbenchFromTemplate` hit the missing-setup-agent // precondition *and* a readiness check confirmed the bench genuinely @@ -100,12 +131,32 @@ export function NewWorkbenchPickerRoute() { // finishes, not a request in flight. const [stillSettingUp, setStillSettingUp] = useState(false); const promptRef = useRef(null); + const { + rootRef: agentPickerRootRef, + triggerRef: agentPickerTriggerRef, + close: dismissAgentPicker, + } = useDismissablePopover({ + closeOnEscape: false, + open: agentPickerOpen, + onOpenChange: setAgentPickerOpen, + }); + const invitableAgents = useQuery({ + queryKey: ["tenant", selectedTenantId, "invitable-definitions"], + queryFn: () => { + if (selectedTenantId === null) { + throw new Error("An account is required to load agents."); + } + return listTenantInvitableDefinitions(selectedTenantId); + }, + enabled: selectedTenantId !== null, + }); // The last attempted create, so "Try again" (both the still-setting-up // dead end and a plain toast-and-retry) replays the exact same request // rather than silently falling back to blank. const lastAttemptRef = useRef<{ readonly templateId: WorkbenchTemplateId; readonly firstMessage: string | undefined; + readonly selectedIds: readonly string[]; } | null>(null); // The prompt box only exists in the DOM once the library read settles @@ -119,6 +170,24 @@ export function NewWorkbenchPickerRoute() { if (showingPrompt) promptRef.current?.focus(); }, [showingPrompt]); + useEffect(() => { + setAgentPickerOpen(false); + setAgentQuery(""); + setActiveAgentIndex(0); + setSelectedAgentDefinitionIds([]); + }, [selectedTenantId]); + + useEffect(() => { + if (invitableAgents.data === undefined) return; + const availableDefinitionIds = new Set( + invitableAgents.data.map((agent) => agent.id), + ); + setSelectedAgentDefinitionIds((current) => { + const next = current.filter((id) => availableDefinitionIds.has(id)); + return next.length === current.length ? current : next; + }); + }, [invitableAgents.data]); + // What this bench's library can actually serve (CL-6458). A kind whose // manifest the library doesn't hold is shown as not set up rather than // offered and then dead-ended on a 404 at create time. @@ -128,18 +197,31 @@ export function NewWorkbenchPickerRoute() { : new Set(); const offeredTemplates = WORKBENCH_TEMPLATES.filter( (template) => - template.id === BLANK_TEMPLATE_ID || servedTemplateIds.has(template.id), + template.id !== BLANK_TEMPLATE_ID && servedTemplateIds.has(template.id), ); const unavailableTemplates = WORKBENCH_TEMPLATES.filter( - (template) => !offeredTemplates.includes(template), + (template) => + template.id !== BLANK_TEMPLATE_ID && !offeredTemplates.includes(template), + ); + const filteredAgents = (invitableAgents.data ?? []).filter((agent) => { + const query = agentQuery.trim().toLocaleLowerCase(); + return ( + query === "" || + agentDisplayName(agent).toLocaleLowerCase().includes(query) || + agent.name.toLocaleLowerCase().includes(query) + ); + }); + const selectedAgents = (invitableAgents.data ?? []).filter((agent) => + selectedAgentDefinitionIds.includes(agent.id), ); async function handleCreate( templateId: WorkbenchTemplateId, firstMessage?: string, + selectedIds: readonly string[] = [], ) { if (selectedTenantId === null || creating) return; - lastAttemptRef.current = { templateId, firstMessage }; + lastAttemptRef.current = { templateId, firstMessage, selectedIds }; setCreating(true); setStillSettingUp(false); try { @@ -149,8 +231,21 @@ export function NewWorkbenchPickerRoute() { navigate, queryClient, firstMessage, + selectedIds, ); } catch (cause) { + if (cause instanceof WorkbenchPostCreateError) { + const refId = reportError(cause, { + operation: "workbench_post_create", + tenantId: selectedTenantId, + roomId: cause.workbenchId, + extra: { stage: cause.stage }, + }); + refreshWorkbenchLists(queryClient, selectedTenantId, cause.workbenchId); + navigate(workbenchPath(cause.workbenchId)); + toast(describeWorkbenchCreateFailure(cause, refId)); + return; + } // 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 @@ -172,15 +267,13 @@ export function NewWorkbenchPickerRoute() { return; } } - log.error("Couldn't create the workbench", { - message: cause instanceof Error ? cause.message : String(cause), - status: - cause instanceof ApiQueryError || cause instanceof ChatApiError - ? cause.status - : undefined, - path: cause instanceof ApiQueryError ? cause.path : undefined, + // Every remaining cause is a genuinely failed create: report it with + // operation context and a quotable refId rather than a log line alone. + const refId = reportError(cause, { + operation: "workbench_create", + tenantId: selectedTenantId, }); - toast(describeWorkbenchCreateFailure(cause)); + toast(describeWorkbenchCreateFailure(cause, refId)); setCreating(false); } } @@ -188,13 +281,37 @@ export function NewWorkbenchPickerRoute() { function retryLastAttempt() { const attempt = lastAttemptRef.current; if (attempt === null) return; - void handleCreate(attempt.templateId, attempt.firstMessage); + void handleCreate( + attempt.templateId, + attempt.firstMessage, + attempt.selectedIds, + ); } function handlePromptSubmit() { const trimmed = prompt.trim(); if (trimmed === "") return; - void handleCreate(BLANK_TEMPLATE_ID, trimmed); + void handleCreate(BLANK_TEMPLATE_ID, trimmed, selectedAgentDefinitionIds); + } + + function toggleAgent(definitionId: string) { + setSelectedAgentDefinitionIds((current) => + current.includes(definitionId) + ? current.filter((id) => id !== definitionId) + : [...current, definitionId], + ); + } + + function closeAgentPicker() { + setAgentQuery(""); + setActiveAgentIndex(0); + dismissAgentPicker(); + } + + function openAgentPicker() { + setAgentPickerOpen(true); + setAgentQuery(""); + setActiveAgentIndex(0); } return ( @@ -251,28 +368,202 @@ export function NewWorkbenchPickerRoute() { handlePromptSubmit(); }} > -