From 39068c8ffd6d67d906be60ffa51f03f9b9349e56 Mon Sep 17 00:00:00 2001
From: 0xPratik
Date: Fri, 4 Sep 2026 20:56:01 +0545
Subject: [PATCH 1/4] Add intent-first workbench composer
---
apps/web/src/app.css | 292 +++++++++++++++--
apps/web/src/instant-agent-create.test.ts | 58 ++++
apps/web/src/instant-agent-create.ts | 20 +-
apps/web/src/pages/new-workbench-picker.tsx | 335 ++++++++++++++++++--
apps/web/test/new-workbench-picker.test.tsx | 113 ++++++-
apps/web/test/toast-single-system.test.tsx | 14 +-
6 files changed, 747 insertions(+), 85 deletions(-)
diff --git a/apps/web/src/app.css b/apps/web/src/app.css
index 5721abcb9f..c7fdaceb3b 100644
--- a/apps/web/src/app.css
+++ b/apps/web/src/app.css
@@ -3404,21 +3404,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);
}
@@ -3438,13 +3440,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) {
@@ -3453,43 +3457,225 @@ tr.insights-row-clickable:hover {
}
.new-workbench-prompt-input {
- flex: 1;
+ width: 100%;
min-width: 0;
resize: none;
border: 0;
background: transparent;
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,
+.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-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-enter-hint {
+ flex-shrink: 0;
+ color: var(--muted-foreground);
+ font-family: ui-monospace, monospace;
+ font-size: 0.75rem;
+}
+
+.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;
}
@@ -3510,8 +3696,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;
@@ -3520,6 +3706,7 @@ tr.insights-row-clickable:hover {
}
.new-workbench-pick-text {
+ flex: 1;
min-width: 0;
}
@@ -3532,11 +3719,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..5f93769de5 100644
--- a/apps/web/src/instant-agent-create.test.ts
+++ b/apps/web/src/instant-agent-create.test.ts
@@ -146,6 +146,64 @@ 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("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..b327deeb66 100644
--- a/apps/web/src/instant-agent-create.ts
+++ b/apps/web/src/instant-agent-create.ts
@@ -156,7 +156,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,13 +227,17 @@ 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 }),
+ );
+ await sendMessage(tenantId, workbench.id, partsForSend(firstMessage, []), {
+ ...(selectedAgentInvites.length > 0
+ ? { invite: selectedAgentInvites }
+ : {}),
+ });
// 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
@@ -245,5 +253,9 @@ export async function createWorkbenchFromTemplate(
}
}
+ await queryClient.invalidateQueries({
+ queryKey: workbenchesQueryKeyPrefix(tenantId),
+ });
+
navigate(workbenchPath(workbench.id));
}
diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx
index cb625a2a43..a7e9ed721d 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,10 +20,12 @@ 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 { useQuery, useQueryClient } from "@tanstack/react-query";
import { getLogger } from "@corbits/client-log";
import { ApiQueryError, describeApiError } from "@corbits/api-query";
@@ -79,6 +82,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 +105,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 +120,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 +159,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 +186,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,6 +220,7 @@ export function NewWorkbenchPickerRoute() {
navigate,
queryClient,
firstMessage,
+ selectedIds,
);
} catch (cause) {
// The missing-setup-agent precondition reads identically whether
@@ -183,13 +255,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 (
@@ -246,28 +342,198 @@ export function NewWorkbenchPickerRoute() {
handlePromptSubmit();
}}
>
-
) : null}
+
+ Or start from a template
+ Agents are already in the room. One click opens it.
+
{offeredTemplates.map((template) => {
const Icon = CARD_ICON[template.id];
@@ -340,6 +610,15 @@ export function NewWorkbenchPickerRoute() {
);
})}
+
>
)}
diff --git a/apps/web/test/new-workbench-picker.test.tsx b/apps/web/test/new-workbench-picker.test.tsx
index c510238ad7..0f25b8be6d 100644
--- a/apps/web/test/new-workbench-picker.test.tsx
+++ b/apps/web/test/new-workbench-picker.test.tsx
@@ -84,6 +84,16 @@ function stubFetch(
}),
);
}
+ if (path.endsWith("/chat/invitable-definitions")) {
+ return Promise.resolve(
+ json({
+ items: [
+ { id: "wfd_scout", name: "scout", description: "Scout" },
+ { id: "wfd_quill", name: "quill", description: "Quill" },
+ ],
+ }),
+ );
+ }
const response = extra(path, init);
if (response !== undefined) return Promise.resolve(response);
throw new Error(`unexpected fetch: ${path}`);
@@ -166,7 +176,13 @@ function prefabCards(): HTMLButtonElement[] {
* manifest, no participants, no settings patch) — shared by every test
* exercising the prompt box's blank-plus-first-message path. */
function stubBlankCreate(
- onSendMessage?: (body: { parts: readonly { kind: string }[] }) => void,
+ onSendMessage?: (body: {
+ readonly parts: readonly { kind: string }[];
+ readonly invite?: readonly {
+ readonly kind: string;
+ readonly definitionId: string;
+ }[];
+ }) => void,
): RecordedCall[] {
return stubFetch((path, init) => {
if (path.includes("/workflows/definitions")) {
@@ -200,6 +216,10 @@ function stubBlankCreate(
) {
const body = JSON.parse(String(init.body)) as {
parts: readonly { kind: string }[];
+ invite?: readonly {
+ readonly kind: string;
+ readonly definitionId: string;
+ }[];
};
onSendMessage?.(body);
return json({ id: "msg_1", createdAt: "2026-01-01T00:00:00.000Z" });
@@ -233,7 +253,7 @@ describe("NewWorkbenchPickerRoute", () => {
expect(document.activeElement).toBe(input);
});
- test("the prefab cards render below the prompt box, one click each — no radio group", async () => {
+ test("the template rows render below the prompt box, with an explicit empty channel", async () => {
stubFetch(() => undefined);
await renderPicker();
@@ -241,12 +261,9 @@ describe("NewWorkbenchPickerRoute", () => {
expect(container?.querySelector('[role="radio"]')).toBeNull();
const cards = prefabCards();
- expect(cards.length).toBe(2);
+ expect(cards.length).toBe(1);
expect(container?.textContent).toContain("Code review");
- expect(container?.textContent).toContain("Just start talking");
- expect(container?.textContent).toContain(
- "An empty channel. Nobody is hosted.",
- );
+ expect(container?.textContent).toContain("just open an empty channel");
});
// The library seeds every shipped template (`createTemplateLibrarySeeder`),
@@ -279,7 +296,7 @@ describe("NewWorkbenchPickerRoute", () => {
await renderPicker();
const cards = prefabCards();
- expect(cards.length).toBe(3);
+ expect(cards.length).toBe(2);
const dueDiligence = cards.find((card) =>
card.textContent?.includes("Due Diligence"),
);
@@ -306,8 +323,7 @@ describe("NewWorkbenchPickerRoute", () => {
await renderPicker();
const cards = prefabCards();
- expect(cards.length).toBe(1);
- expect(cards[0]?.textContent).toContain("Just start talking");
+ expect(cards.length).toBe(0);
expect(container?.textContent).toContain("Code review");
expect(container?.textContent).toContain("Not set up on this bench yet");
});
@@ -328,8 +344,7 @@ describe("NewWorkbenchPickerRoute", () => {
expect(container?.textContent).toContain(
"Couldn't load what this bench can set up",
);
- expect(prefabCards().length).toBe(1);
- expect(prefabCards()[0]?.textContent).toContain("Just start talking");
+ expect(prefabCards().length).toBe(0);
});
test("typing a goal and hitting Enter creates a blank workbench and delivers the goal as the first message", async () => {
@@ -375,6 +390,76 @@ describe("NewWorkbenchPickerRoute", () => {
]);
});
+ test("a selected agent is pre-invited with the opening message", async () => {
+ let sentInvite:
+ | readonly { readonly kind: string; readonly definitionId: string }[]
+ | undefined;
+ const calls = stubBlankCreate((body) => {
+ sentInvite = body.invite;
+ });
+ const navigated: string[] = [];
+ await renderPicker((to) => navigated.push(to));
+
+ for (let i = 0; i < 20; i++) {
+ await settle();
+ if (container?.textContent?.includes("+ Add agent")) break;
+ }
+ const addAgent = Array.from(
+ container?.querySelectorAll("button") ?? [],
+ ).find((button) => button.textContent === "+ Add agent");
+ await act(async () => {
+ addAgent?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ const scout = document.querySelector(
+ '[role="option"]#new-workbench-agent-wfd_scout',
+ );
+ await act(async () => {
+ scout?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ typeIntoPrompt("Research our next partner");
+ });
+ await act(async () => {
+ promptInput()?.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
+ );
+ });
+ for (let i = 0; i < 20; i++) {
+ await settle();
+ if (navigated.length > 0) break;
+ }
+
+ expect(sentInvite).toEqual([{ kind: "agent", definitionId: "wfd_scout" }]);
+ expect(calls.some((call) => call.path.endsWith("/invite"))).toBe(false);
+ expect(navigated).toEqual(["/w/chan_new"]);
+ });
+
+ test("Escape closes the agent picker and returns focus to its trigger", async () => {
+ stubFetch(() => undefined);
+ await renderPicker();
+
+ for (let i = 0; i < 20; i++) {
+ await settle();
+ if (container?.textContent?.includes("+ Add agent")) break;
+ }
+ const addAgent = Array.from(
+ container?.querySelectorAll("button") ?? [],
+ ).find((button) => button.textContent === "+ Add agent");
+ await act(async () => {
+ addAgent?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ const search = document.querySelector(
+ 'input[role="combobox"]',
+ );
+ await act(async () => {
+ search?.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
+ );
+ });
+ await settle();
+
+ expect(document.querySelector(".new-workbench-agent-popover")).toBeNull();
+ expect(document.activeElement).toBe(addAgent ?? null);
+ });
+
test("Shift+Enter does not submit — the prompt box stays open for a new line", async () => {
stubFetch(() => undefined);
await renderPicker();
@@ -436,8 +521,8 @@ describe("NewWorkbenchPickerRoute", () => {
});
await renderPicker();
- const justTalk = prefabCards().find((card) =>
- card.textContent?.includes("Just start talking"),
+ const justTalk = container?.querySelector(
+ ".new-workbench-empty-channel",
);
await act(async () => {
justTalk?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
diff --git a/apps/web/test/toast-single-system.test.tsx b/apps/web/test/toast-single-system.test.tsx
index 018ea84b7a..f118b1351d 100644
--- a/apps/web/test/toast-single-system.test.tsx
+++ b/apps/web/test/toast-single-system.test.tsx
@@ -141,12 +141,8 @@ async function renderPickerWithToaster(): Promise {
}
}
-function justStartTalkingCard(): HTMLButtonElement | undefined {
- return Array.from(
- container?.querySelectorAll(
- "button.new-workbench-prefab-card",
- ) ?? [],
- ).find((card) => card.textContent?.includes("Just start talking"));
+function emptyChannelButton(): HTMLButtonElement | null {
+ return container?.querySelector(".new-workbench-empty-channel") ?? null;
}
describe("the one toast system (CL-6372)", () => {
@@ -163,7 +159,7 @@ describe("the one toast system (CL-6372)", () => {
await renderPickerWithToaster();
await act(async () => {
- justStartTalkingCard()?.dispatchEvent(
+ emptyChannelButton()?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
@@ -213,7 +209,7 @@ describe("the one toast system (CL-6372)", () => {
await renderPickerWithToaster();
await act(async () => {
- justStartTalkingCard()?.dispatchEvent(
+ emptyChannelButton()?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
@@ -231,7 +227,7 @@ describe("the one toast system (CL-6372)", () => {
await renderPickerWithToaster();
await act(async () => {
- justStartTalkingCard()?.dispatchEvent(
+ emptyChannelButton()?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
From 254e0ba97b9ecb4722dde51a0efb96ed2950d7ff Mon Sep 17 00:00:00 2001
From: 0xPratik
Date: Fri, 4 Sep 2026 21:47:35 +0545
Subject: [PATCH 2/4] Fix navigation after blank workbench setup fails
---
apps/web/src/instant-agent-create.test.ts | 45 +++++++++++++++++
apps/web/src/instant-agent-create.ts | 49 ++++++++++++++++---
.../src/pages/new-workbench-picker.test.ts | 20 +++++++-
apps/web/src/pages/new-workbench-picker.tsx | 30 +++++++++++-
apps/web/test/new-workbench-picker.test.tsx | 27 +++++++++-
scripts/checks/report-error-baseline.txt | 1 -
6 files changed, 160 insertions(+), 12 deletions(-)
diff --git a/apps/web/src/instant-agent-create.test.ts b/apps/web/src/instant-agent-create.test.ts
index 5f93769de5..7dbee7e197 100644
--- a/apps/web/src/instant-agent-create.test.ts
+++ b/apps/web/src/instant-agent-create.test.ts
@@ -8,6 +8,7 @@ import {
import {
createWorkbenchFromTemplate,
NEW_WORKBENCH_TITLE,
+ WorkbenchPostCreateError,
} from "./instant-agent-create";
function newQueryClient(): QueryClient {
@@ -204,6 +205,50 @@ describe("createWorkbenchFromTemplate", () => {
expect(calls.some((call) => call.path.endsWith("/invite"))).toBe(false);
});
+ 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 b327deeb66..6991d1fd5f 100644
--- a/apps/web/src/instant-agent-create.ts
+++ b/apps/web/src/instant-agent-create.ts
@@ -73,6 +73,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
@@ -233,20 +243,43 @@ export async function createWorkbenchFromTemplate(
const selectedAgentInvites = [...new Set(selectedAgentDefinitionIds)].map(
(definitionId) => ({ kind: "agent" as const, definitionId }),
);
- await sendMessage(tenantId, workbench.id, partsForSend(firstMessage, []), {
- ...(selectedAgentInvites.length > 0
- ? { invite: selectedAgentInvites }
- : {}),
- });
+ 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,
- });
+ try {
+ await patchWorkbenchSettings(tenantId, workbench.id, {
+ "chat/name": autoTitle,
+ });
+ } catch (cause) {
+ if (templateId === "blank") {
+ throw new WorkbenchPostCreateError(workbench.id, "rename", cause);
+ }
+ throw cause;
+ }
await queryClient.invalidateQueries({
queryKey: workbenchesQueryKeyPrefix(tenantId),
});
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 a7e9ed721d..378ab7dec9 100644
--- a/apps/web/src/pages/new-workbench-picker.tsx
+++ b/apps/web/src/pages/new-workbench-picker.tsx
@@ -22,23 +22,27 @@ import {
describeChatError,
listTenantInvitableDefinitions,
WorkbenchLoadingState,
+ workbenchesQueryKeyPrefix,
} from "@corbits/chat-ui";
import { humanizeSlug } from "@corbits/chat/display-name";
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getLogger } from "@corbits/client-log";
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,
+ 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,
@@ -60,7 +64,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");
@@ -223,6 +237,20 @@ export function NewWorkbenchPickerRoute() {
selectedIds,
);
} catch (cause) {
+ if (cause instanceof WorkbenchPostCreateError) {
+ const refId = reportError(cause, {
+ operation: "workbench_post_create",
+ tenantId: selectedTenantId,
+ roomId: cause.workbenchId,
+ extra: { stage: cause.stage },
+ });
+ await queryClient.invalidateQueries({
+ queryKey: workbenchesQueryKeyPrefix(selectedTenantId),
+ });
+ 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
diff --git a/apps/web/test/new-workbench-picker.test.tsx b/apps/web/test/new-workbench-picker.test.tsx
index 0f25b8be6d..f64db4aaca 100644
--- a/apps/web/test/new-workbench-picker.test.tsx
+++ b/apps/web/test/new-workbench-picker.test.tsx
@@ -183,6 +183,7 @@ function stubBlankCreate(
readonly definitionId: string;
}[];
}) => void,
+ messageResponse?: Response,
): RecordedCall[] {
return stubFetch((path, init) => {
if (path.includes("/workflows/definitions")) {
@@ -222,7 +223,10 @@ function stubBlankCreate(
}[];
};
onSendMessage?.(body);
- return json({ id: "msg_1", createdAt: "2026-01-01T00:00:00.000Z" });
+ return (
+ messageResponse ??
+ json({ id: "msg_1", createdAt: "2026-01-01T00:00:00.000Z" })
+ );
}
if (
path.endsWith("/chat/workbenches/chan_new/settings") &&
@@ -390,6 +394,27 @@ describe("NewWorkbenchPickerRoute", () => {
]);
});
+ test("a failed opening message still opens the blank workbench", async () => {
+ stubBlankCreate(undefined, json({ error: "agent launch failed" }, 409));
+ const navigated: string[] = [];
+ await renderPicker((to) => navigated.push(to));
+
+ await act(async () => {
+ typeIntoPrompt("Research our next partner");
+ });
+ await act(async () => {
+ promptInput()?.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
+ );
+ });
+ for (let i = 0; i < 20; i++) {
+ await settle();
+ if (navigated.length > 0) break;
+ }
+
+ expect(navigated).toEqual(["/w/chan_new"]);
+ });
+
test("a selected agent is pre-invited with the opening message", async () => {
let sentInvite:
| readonly { readonly kind: string; readonly definitionId: string }[]
diff --git a/scripts/checks/report-error-baseline.txt b/scripts/checks/report-error-baseline.txt
index dfc9dfa220..c34d630753 100644
--- a/scripts/checks/report-error-baseline.txt
+++ b/scripts/checks/report-error-baseline.txt
@@ -85,7 +85,6 @@ apps/web/src/pages/create-agent-panel.tsx 1 setSubmitError(
apps/web/src/pages/create-skill-dialog.tsx 1 setServerError(cause instanceof Error ? cause.message : String(cause));
apps/web/src/pages/library-page.tsx 1 setUploadError(
apps/web/src/pages/mission-control-page.tsx 1 toast(
-apps/web/src/pages/new-workbench-picker.tsx 1 // The missing-setup-agent precondition reads identically whether
apps/web/src/pages/skill-detail-page.tsx 1 if (options.keepDraft) {
apps/web/src/pages/skill-detail-page.tsx 1 if (statusOf(cause) === 409) {
apps/web/src/pages/skill-detail-page.tsx 1 setActionError(describeApiError(cause, "reading that version"));
From d00b6b3d69eec3fa9093bffc9907313696431998 Mon Sep 17 00:00:00 2001
From: 0xPratik
Date: Sun, 6 Sep 2026 15:46:06 +0545
Subject: [PATCH 3/4] Restore reliable workbench replies
---
apps/web/src/app.css | 21 ++--
apps/web/src/instant-agent-create.test.ts | 66 +++++++++-
apps/web/src/instant-agent-create.ts | 28 +++--
apps/web/src/pages/new-workbench-picker.tsx | 9 +-
apps/web/test/new-workbench-picker.test.tsx | 40 +++++-
packages/chat/src/agent-turns.test.ts | 11 +-
packages/chat/src/agent-turns.ts | 92 +++++++++++---
packages/chat/src/chat-orchestrator.ts | 39 +++++-
packages/chat/src/migrations.ts | 26 ++++
packages/chat/src/schema.ts | 1 -
.../chat/test/agent-turns.drizzle.test.ts | 11 +-
packages/chat/test/chat-orchestrator.test.ts | 114 +++++++++++++++++-
packages/chat/test/migrations.test.ts | 1 +
13 files changed, 407 insertions(+), 52 deletions(-)
diff --git a/apps/web/src/app.css b/apps/web/src/app.css
index c7fdaceb3b..57621a30d2 100644
--- a/apps/web/src/app.css
+++ b/apps/web/src/app.css
@@ -3462,6 +3462,7 @@ tr.insights-row-clickable:hover {
resize: none;
border: 0;
background: transparent;
+ caret-color: var(--shell-accent);
font: inherit;
font-size: 1.25rem;
line-height: 1.45;
@@ -3507,13 +3508,24 @@ tr.insights-row-clickable:hover {
cursor: pointer;
}
-.new-workbench-prompt-examples button:hover,
+.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;
@@ -3637,13 +3649,6 @@ tr.insights-row-clickable:hover {
padding: 0.75rem 0.5rem;
}
-.new-workbench-enter-hint {
- flex-shrink: 0;
- color: var(--muted-foreground);
- font-family: ui-monospace, monospace;
- font-size: 0.75rem;
-}
-
.new-workbench-prefab-grid {
display: flex;
flex-direction: column;
diff --git a/apps/web/src/instant-agent-create.test.ts b/apps/web/src/instant-agent-create.test.ts
index 7dbee7e197..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,
@@ -205,6 +205,70 @@ describe("createWorkbenchFromTemplate", () => {
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) => {
diff --git a/apps/web/src/instant-agent-create.ts b/apps/web/src/instant-agent-create.ts
index 6991d1fd5f..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
@@ -280,15 +299,10 @@ export async function createWorkbenchFromTemplate(
}
throw cause;
}
- await queryClient.invalidateQueries({
- queryKey: workbenchesQueryKeyPrefix(tenantId),
- });
+ refreshWorkbenchLists(queryClient, tenantId, workbench.id);
}
}
- await queryClient.invalidateQueries({
- queryKey: workbenchesQueryKeyPrefix(tenantId),
- });
-
+ refreshWorkbenchLists(queryClient, tenantId, workbench.id);
navigate(workbenchPath(workbench.id));
}
diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx
index 378ab7dec9..b6a668b31a 100644
--- a/apps/web/src/pages/new-workbench-picker.tsx
+++ b/apps/web/src/pages/new-workbench-picker.tsx
@@ -22,7 +22,6 @@ import {
describeChatError,
listTenantInvitableDefinitions,
WorkbenchLoadingState,
- workbenchesQueryKeyPrefix,
} from "@corbits/chat-ui";
import { humanizeSlug } from "@corbits/chat/display-name";
import { useEffect, useRef, useState } from "react";
@@ -36,6 +35,7 @@ import { TemplateLibraryPage } from "../workbench-templates-api";
import { useBench } from "../bench-context";
import {
createWorkbenchFromTemplate,
+ refreshWorkbenchLists,
WorkbenchPostCreateError,
WorkbenchPreconditionError,
} from "../instant-agent-create";
@@ -244,9 +244,7 @@ export function NewWorkbenchPickerRoute() {
roomId: cause.workbenchId,
extra: { stage: cause.stage },
});
- await queryClient.invalidateQueries({
- queryKey: workbenchesQueryKeyPrefix(selectedTenantId),
- });
+ refreshWorkbenchLists(queryClient, selectedTenantId, cause.workbenchId);
navigate(workbenchPath(cause.workbenchId));
toast(describeWorkbenchCreateFailure(cause, refId));
return;
@@ -398,6 +396,8 @@ export function NewWorkbenchPickerRoute() {