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(); }} > -