Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 35 additions & 26 deletions apps/web/src/bench/pending-opening-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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("@"));
Expand All @@ -60,32 +64,37 @@ export function PendingOpeningMessage({
const failed = send.isError || (timedOut && !live);

return (
<div className="chat-thread-message" data-author="me" data-pending="true">
<div className="chat-thread-body">
<div className="chat-thread-own-bubble">{text}</div>
{failed ? (
<p className="chat-thread-error">
This message hasn&apos;t been sent yet.{" "}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
if (live) send.mutate(text);
else
void queryClient.invalidateQueries({
queryKey: workbenchKeys.participants(workbenchTenantId),
});
setTimedOut(false);
}}
>
Retry
</Button>
</p>
) : (
<p className="chat-thread-status">Sending once the agent is ready…</p>
)}
<>
<div className="chat-thread-message" data-author="me" data-pending="true">
<div className="chat-thread-body">
<div className="chat-thread-own-bubble">{text}</div>
{failed ? (
<p className="chat-thread-error">
This message hasn&apos;t been sent yet.{" "}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
if (live) send.mutate(text);
else
void queryClient.invalidateQueries({
queryKey: workbenchKeys.participants(workbenchTenantId),
});
setTimedOut(false);
}}
>
Retry
</Button>
</p>
) : (
<p className="chat-thread-status">Sending once the agent is ready…</p>
)}
</div>
</div>
</div>
{/* Once live, the send marks the turn pending and the page's own
working label takes over until the reply lands. */}
{failed || live ? null : <WorkingLabel />}
</>
);
}
8 changes: 8 additions & 0 deletions apps/web/src/chat/working-label.css
Original file line number Diff line number Diff line change
@@ -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%);
}
3 changes: 2 additions & 1 deletion apps/web/src/chat/working-label.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ThinkingLabel } from "@corbits/react-ui";
import { ThinkingLabel, ThinkingMark } from "@corbits/react-ui";

import "./working-label.css";

Expand All @@ -8,6 +8,7 @@ const VERBS = ["Thinking…", "Working on it…"];
export function WorkingLabel() {
return (
<div className="chat-thread-working">
<ThinkingMark variant="silk" className="chat-thread-working-mark" />
<ThinkingLabel verbs={VERBS} intervalMs={3000} />
</div>
);
Expand Down
13 changes: 4 additions & 9 deletions e2e/browser/first-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -95,9 +95,7 @@ async function signUp(page: Page, origin: string): Promise<void> {
}

async function createBench(page: Page, message: string): Promise<string> {
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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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,
});
Expand Down
6 changes: 2 additions & 4 deletions e2e/browser/ollama-chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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,
});
Expand Down
21 changes: 6 additions & 15 deletions e2e/browser/zero-to-one.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
42 changes: 25 additions & 17 deletions e2e/lib/first-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<void> {
await page.goto(origin, { waitUntil: "networkidle0" });

Expand All @@ -38,23 +62,7 @@ export async function runFirstRunFlow(page: Page, origin: string): Promise<void>

// 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 });
Expand Down
Loading