From 9982633a207fa0d08b5a17541b183318659ad645 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 8 Oct 2026 10:47:28 +0545 Subject: [PATCH 1/3] e2e: start benches from /new only once the composer is enabled The /new composer and its send button stay disabled until workspace roles have loaded (CL-9780). Suites that typed straight away lost the input and stalled on /new waiting for a bench that was never created. One shared startWorkbench(page, message) helper in e2e/lib/first-run.ts now waits for both before typing and clicking, and every suite that starts a bench uses it. --- e2e/browser/composer-layout.test.ts | 6 ++--- e2e/browser/first-run.test.ts | 6 ++--- e2e/browser/ollama-chat.test.ts | 6 ++--- e2e/browser/reply.test.ts | 6 ++--- e2e/browser/setup.test.ts | 4 +-- e2e/browser/zero-to-one.test.ts | 6 ++--- e2e/lib/first-run.ts | 42 +++++++++++++++++------------ 7 files changed, 37 insertions(+), 39 deletions(-) diff --git a/e2e/browser/composer-layout.test.ts b/e2e/browser/composer-layout.test.ts index 7d64f07f3..3831ce49c 100644 --- a/e2e/browser/composer-layout.test.ts +++ b/e2e/browser/composer-layout.test.ts @@ -2,7 +2,7 @@ // wrap) and sidebar rows go full-width with their radius intact. import { expect, test } from "bun:test"; import { bootBrowserApp, browserGate } from "../lib/browser"; -import { clickText, STEP_TIMEOUT, waitForText } from "../lib/first-run"; +import { clickText, STEP_TIMEOUT, startWorkbench, waitForText } from "../lib/first-run"; const describeBrowser = browserGate(import.meta.path); @@ -69,9 +69,7 @@ describeBrowser("composer layout", () => { // Create the bench; its sidebar row is populated and active. await page.goto(`${app().origin}/new`, { waitUntil: "networkidle0" }); - await page.waitForSelector(".chat-composer-input", { timeout: STEP_TIMEOUT }); - await page.type(".chat-composer-input", "Summarize what shipped this week"); - await page.click("button[aria-label='Start this workbench']"); + await startWorkbench(page, "Summarize what shipped this week"); await page.waitForFunction(`location.pathname.startsWith("/w/")`, { timeout: STEP_TIMEOUT, }); diff --git a/e2e/browser/first-run.test.ts b/e2e/browser/first-run.test.ts index d65e68d1a..b687c3578 100644 --- a/e2e/browser/first-run.test.ts +++ b/e2e/browser/first-run.test.ts @@ -7,7 +7,7 @@ import { expect, test } from "bun:test"; import type { Page } from "puppeteer-core"; import { bootBrowserApp, browserGate } from "../lib/browser"; -import { clickText, STEP_TIMEOUT, waitForText } from "../lib/first-run"; +import { clickText, STEP_TIMEOUT, startWorkbench, waitForText } from "../lib/first-run"; const describeBrowser = browserGate(import.meta.path); const MESSAGES = [ @@ -40,9 +40,7 @@ async function signUp(page: Page, origin: string): Promise { } async function createBench(page: Page, message: string): Promise { - await page.waitForSelector("textarea", { timeout: STEP_TIMEOUT }); - await page.type("textarea", message); - await page.click("button[aria-label='Start this workbench']"); + await startWorkbench(page, message); await page.waitForFunction(`location.pathname.startsWith("/w/")`, { timeout: STEP_TIMEOUT }); await waitForText(page, message); return new URL(page.url()).pathname.slice("/w/".length); diff --git a/e2e/browser/ollama-chat.test.ts b/e2e/browser/ollama-chat.test.ts index 322ccb1e2..187c1e01d 100644 --- a/e2e/browser/ollama-chat.test.ts +++ b/e2e/browser/ollama-chat.test.ts @@ -6,7 +6,7 @@ // OLLAMA_MODEL (default qwen2.5:7b). import { expect, test } from "bun:test"; import { bootBrowserApp, browserGate } from "../lib/browser"; -import { clickText, STEP_TIMEOUT, waitForText } from "../lib/first-run"; +import { clickText, STEP_TIMEOUT, startWorkbench, waitForText } from "../lib/first-run"; const describeBrowser = browserGate(import.meta.path); @@ -75,9 +75,7 @@ describeBrowser("ollama chat", () => { ); await clickText(page, "button", "Connect"); - await page.waitForSelector("textarea", { timeout: STEP_TIMEOUT }); - await page.type("textarea", PROMPT); - await page.click("button[aria-label='Start this workbench']"); + await startWorkbench(page, PROMPT); await page.waitForFunction(`location.pathname.startsWith("/w/")`, { timeout: STEP_TIMEOUT, }); diff --git a/e2e/browser/reply.test.ts b/e2e/browser/reply.test.ts index 3842f65be..2d85ea6a8 100644 --- a/e2e/browser/reply.test.ts +++ b/e2e/browser/reply.test.ts @@ -5,7 +5,7 @@ import { expect, test } from "bun:test"; import { bootAimock, MOCK_REPLY } from "../lib/aimock"; import { bootBrowserApp, browserGate } from "../lib/browser"; -import { clickText, STEP_TIMEOUT, waitForText } from "../lib/first-run"; +import { clickText, STEP_TIMEOUT, startWorkbench, waitForText } from "../lib/first-run"; const describeBrowser = browserGate(import.meta.path); @@ -40,9 +40,7 @@ describeBrowser("worker reply", () => { await model[1]?.type("mock-model"); await clickText(page, "button", "Connect"); - await page.waitForSelector("textarea", { timeout: STEP_TIMEOUT }); - await page.type("textarea", "Say hello"); - await page.click("button[aria-label='Start this workbench']"); + await startWorkbench(page, "Say hello"); await page.waitForFunction(`location.pathname.startsWith("/w/")`, { timeout: STEP_TIMEOUT, }); diff --git a/e2e/browser/setup.test.ts b/e2e/browser/setup.test.ts index cf9edb970..cc066a645 100644 --- a/e2e/browser/setup.test.ts +++ b/e2e/browser/setup.test.ts @@ -5,6 +5,7 @@ import { expect, test } from "bun:test"; import type { Page } from "puppeteer-core"; import { bootBrowserApp, browserGate } from "../lib/browser"; +import { startWorkbench } from "../lib/first-run"; const describeBrowser = browserGate(import.meta.path); const STEP_TIMEOUT = 60_000; @@ -92,8 +93,7 @@ describeBrowser("client setup", () => { expect(JSON.stringify(mine[0]?.["roles"])).toContain("owner"); // The first bench carries the first worker. - await page.type("textarea", "Summarize what shipped this week"); - await page.click("button[aria-label='Start this workbench']"); + await startWorkbench(page, "Summarize what shipped this week"); await page.waitForFunction(`location.pathname.startsWith("/w/")`, { timeout: STEP_TIMEOUT }); const benchId = new URL(page.url()).pathname.slice("/w/".length); diff --git a/e2e/browser/zero-to-one.test.ts b/e2e/browser/zero-to-one.test.ts index ed1d5dea5..768ff1f62 100644 --- a/e2e/browser/zero-to-one.test.ts +++ b/e2e/browser/zero-to-one.test.ts @@ -4,7 +4,7 @@ import { expect, test } from "bun:test"; import { bootAimock, MOCK_REPLY, type Fixture } from "../lib/aimock"; import { bootBrowserApp, browserGate } from "../lib/browser"; -import { clickText, STEP_TIMEOUT, waitForText } from "../lib/first-run"; +import { clickText, STEP_TIMEOUT, startWorkbench, waitForText } from "../lib/first-run"; const describeBrowser = browserGate(import.meta.path); @@ -98,9 +98,7 @@ describeBrowser("0-to-1 gate", () => { await fields[1]?.type("mock-model"); await clickText(page, "button", "Connect"); - await page.waitForSelector("textarea", { timeout: STEP_TIMEOUT }); - await page.type("textarea", "Say hello"); - await page.click("button[aria-label='Start this workbench']"); + await startWorkbench(page, "Say hello"); await page.waitForFunction(`location.pathname.startsWith("/w/")`, { timeout: STEP_TIMEOUT }); await waitForText(page, MOCK_REPLY); diff --git a/e2e/lib/first-run.ts b/e2e/lib/first-run.ts index dd5749252..694e52e26 100644 --- a/e2e/lib/first-run.ts +++ b/e2e/lib/first-run.ts @@ -19,6 +19,30 @@ export async function clickText(page: Page, selector: string, label: string): Pr await page.evaluate(`(${script}).click()`); } +/** + * Types `message` into the /new composer and starts the workbench. The + * composer and its send button stay disabled until workspace roles have + * loaded (CL-9780), so typing or clicking earlier is silently lost. + */ +export async function startWorkbench(page: Page, message: string): Promise { + await page.waitForFunction( + `(() => { + const input = document.querySelector("textarea"); + return input instanceof HTMLTextAreaElement && !input.disabled; + })()`, + { timeout: STEP_TIMEOUT }, + ); + await page.type("textarea", message); + await page.waitForFunction( + `(() => { + const send = document.querySelector("button[aria-label='Start this workbench']"); + return send instanceof HTMLButtonElement && !send.disabled; + })()`, + { timeout: STEP_TIMEOUT }, + ); + await page.click("button[aria-label='Start this workbench']"); +} + export async function runFirstRunFlow(page: Page, origin: string): Promise { await page.goto(origin, { waitUntil: "networkidle0" }); @@ -38,23 +62,7 @@ export async function runFirstRunFlow(page: Page, origin: string): Promise // Connecting hands off to the new-workbench prompt (a workspace with no // workbenches); the first bench's worker is the first worker. - // The composer stays disabled until workspace roles have loaded (CL-9780). - await page.waitForFunction( - `(() => { - const input = document.querySelector("textarea"); - return input instanceof HTMLTextAreaElement && !input.disabled; - })()`, - { timeout: STEP_TIMEOUT }, - ); - await page.type("textarea", FIRST_WORKBENCH); - await page.waitForFunction( - `(() => { - const send = document.querySelector("button[aria-label='Start this workbench']"); - return send instanceof HTMLButtonElement && !send.disabled; - })()`, - { timeout: STEP_TIMEOUT }, - ); - await page.click("button[aria-label='Start this workbench']"); + await startWorkbench(page, FIRST_WORKBENCH); // Creating the workbench navigates to its own page, which shows the prompt. await page.waitForFunction(`location.pathname.startsWith("/w/")`, { timeout: STEP_TIMEOUT }); From 695250db768849237bc8f6c3203b6ad9f44126c2 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 8 Oct 2026 10:47:37 +0545 Subject: [PATCH 2/3] Add tests for the working label on a bench's first message After creating a bench from /new, the thread shows the working label while the opening message waits on the worker to start and reply. --- e2e/browser/reply.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/e2e/browser/reply.test.ts b/e2e/browser/reply.test.ts index 2d85ea6a8..ddb561b5e 100644 --- a/e2e/browser/reply.test.ts +++ b/e2e/browser/reply.test.ts @@ -1,7 +1,7 @@ // A fresh user connects an inference provider served by aimock, creates a -// bench from /new, and the worker's reply lands in the thread while the -// bench stops showing "working". The reply names the worker, which then shows -// everywhere; a rename in the bench drawer survives a reload. +// bench from /new, and the bench shows "working" from the first message +// until the worker's reply lands. The reply names the worker, which then +// shows everywhere; a rename in the bench drawer survives a reload. import { expect, test } from "bun:test"; import { bootAimock, MOCK_REPLY } from "../lib/aimock"; import { bootBrowserApp, browserGate } from "../lib/browser"; @@ -45,6 +45,10 @@ describeBrowser("worker reply", () => { timeout: STEP_TIMEOUT, }); + // The opening message is still waiting on the worker to start and + // reply, so the thread says so. + await page.waitForSelector(".chat-thread-working", { timeout: STEP_TIMEOUT }); + await waitForText(page, MOCK_REPLY); await page.waitForFunction(`!document.body.innerText.toLowerCase().includes("working")`, { timeout: STEP_TIMEOUT, From b6e487b5f7d531ddcd999fd31279c979e5126cdf Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 8 Oct 2026 10:47:37 +0545 Subject: [PATCH 3/3] Working label: shows from a bench's first message, beside the Corbits mark The opening message is sent by PendingOpeningMessage, which never marked a turn pending, so a new bench showed nothing while its worker started and replied. The send now marks the turn pending (and clears it on error), and the label shows under the pending message while the worker is still starting; once live, the page's own label takes over until the reply lands. The label now pairs react-ui's ThinkingMark (Silk) with the existing shimmering ThinkingLabel. --- .../web/src/bench/pending-opening-message.tsx | 61 +++++++++++-------- apps/web/src/chat/working-label.css | 8 +++ apps/web/src/chat/working-label.tsx | 3 +- 3 files changed, 45 insertions(+), 27 deletions(-) diff --git a/apps/web/src/bench/pending-opening-message.tsx b/apps/web/src/bench/pending-opening-message.tsx index 0c2807e63..e203816af 100644 --- a/apps/web/src/bench/pending-opening-message.tsx +++ b/apps/web/src/bench/pending-opening-message.tsx @@ -7,8 +7,10 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import { sendToWorkbench, type WorkbenchParticipant } from "@/chat/threads-api"; +import { WorkingLabel } from "@/chat/working-label"; import { workbenchKeys } from "../chat-path"; import { clearOpeningMessage, readOpeningMessage } from "../opening-message"; +import { markTurnPending } from "../worker-status"; import "./pending-opening-message.css"; const CAP_MS = 60_000; @@ -37,11 +39,13 @@ export function PendingOpeningMessage({ participants, content, }), + onMutate: () => markTurnPending(queryClient, workbenchTenantId), onSuccess: () => { clearOpeningMessage(workbenchTenantId); setText(null); void queryClient.invalidateQueries({ queryKey: workbenchKeys.scope(workbenchTenantId) }); }, + onError: () => queryClient.setQueryData(workbenchKeys.pendingTurn(workbenchTenantId), null), }); const live = participants.some((p) => p.kind === "agent" && p.address.includes("@")); @@ -60,32 +64,37 @@ export function PendingOpeningMessage({ const failed = send.isError || (timedOut && !live); return ( -
-
-
{text}
- {failed ? ( -

- This message hasn't been sent yet.{" "} - -

- ) : ( -

Sending once the agent is ready…

- )} + <> +
+
+
{text}
+ {failed ? ( +

+ This message hasn't been sent yet.{" "} + +

+ ) : ( +

Sending once the agent is ready…

+ )} +
-
+ {/* Once live, the send marks the turn pending and the page's own + working label takes over until the reply lands. */} + {failed || live ? null : } + ); } diff --git a/apps/web/src/chat/working-label.css b/apps/web/src/chat/working-label.css index de755144b..bcd3d57eb 100644 --- a/apps/web/src/chat/working-label.css +++ b/apps/web/src/chat/working-label.css @@ -1,4 +1,12 @@ .chat-thread-working { + display: flex; + align-items: center; + gap: 6px; padding: 4px 0 4px 42px; font-size: 13px; } + +/* The mountain carries more visual weight below its SVG center. */ +.chat-thread-working-mark { + transform: translateY(-8%); +} diff --git a/apps/web/src/chat/working-label.tsx b/apps/web/src/chat/working-label.tsx index bf18c050d..01b5f4eba 100644 --- a/apps/web/src/chat/working-label.tsx +++ b/apps/web/src/chat/working-label.tsx @@ -1,4 +1,4 @@ -import { ThinkingLabel } from "@corbits/react-ui"; +import { ThinkingLabel, ThinkingMark } from "@corbits/react-ui"; import "./working-label.css"; @@ -8,6 +8,7 @@ const VERBS = ["Thinking…", "Working on it…"]; export function WorkingLabel() { return (
+
);