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