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 (
+
); diff --git a/e2e/browser/first-run.test.ts b/e2e/browser/first-run.test.ts index 71aef9857..400dd1688 100644 --- a/e2e/browser/first-run.test.ts +++ b/e2e/browser/first-run.test.ts @@ -8,7 +8,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); @@ -95,9 +95,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); @@ -214,8 +212,7 @@ describeBrowser("first run", () => { 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); @@ -294,9 +291,7 @@ describeBrowser("first run", () => { // 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/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/zero-to-one.test.ts b/e2e/browser/zero-to-one.test.ts index e45ed1850..85a30ba3f 100644 --- a/e2e/browser/zero-to-one.test.ts +++ b/e2e/browser/zero-to-one.test.ts @@ -5,7 +5,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); @@ -110,23 +110,14 @@ describeBrowser("0-to-1 gate", () => { expect(inputs.length).toBeGreaterThanOrEqual(3); await clickText(page, "button", "Connect"); - await page.waitForSelector("textarea", { timeout: STEP_TIMEOUT }); - await page.type("textarea", "Say hello"); - // Wait for the composer's enabled state before clicking: the send - // button enables after the workspace roles load (CL-9780), and a - // click while the first bench is still bootstrapping races the - // navigate-to-/w/ assertion below (a known non-deterministic flake). - 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, "Say hello"); await page.waitForFunction(`location.pathname.startsWith("/w/")`, { timeout: STEP_TIMEOUT }); const benchUrl = page.url(); + await page.waitForSelector(".chat-thread-working", { timeout: STEP_TIMEOUT }); await waitForText(page, MOCK_REPLY); + await page.waitForFunction(`!document.querySelector(".chat-thread-working")`, { + timeout: STEP_TIMEOUT, + }); // The worker reply proves the scripted inference provider was dialed, // not just the UI shell loading. expect(aimock().journal().length).toBeGreaterThan(0); 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 });