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
92 changes: 92 additions & 0 deletions apps/web/src/bench/pending-opening-message.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { QueryClient } from "@tanstack/react-query";

import type { WorkbenchParticipant } from "@/chat/threads-api";
import { workbenchKeys } from "../chat-path";
import { parkOpeningMessage, readOpeningMessage } from "../opening-message";
import {
createPendingOpeningMessageSession,
dispatchOpeningMessage,
syncPendingOpeningMessageSession,
} from "./pending-opening-message";

const store = new Map<string, string>();

beforeEach(() => {
store.clear();
Object.defineProperty(globalThis, "sessionStorage", {
configurable: true,
value: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
removeItem: (key: string) => void store.delete(key),
},
});
});

afterEach(() => {
Reflect.deleteProperty(globalThis, "sessionStorage");
});

describe("opening message tenant isolation", () => {
test("switching workbenches resets pending session state to the new tenant", () => {
parkOpeningMessage("tnt_bench_a", "Draft the Q4 launch note");
const sessionA = {
...createPendingOpeningMessageSession("tnt_bench_a"),
timedOut: true,
};

const sessionB = syncPendingOpeningMessageSession(sessionA, "tnt_bench_b");
expect(sessionB).toEqual({
workbenchTenantId: "tnt_bench_b",
text: null,
timedOut: false,
});
expect(readOpeningMessage("tnt_bench_a")).toBe("Draft the Q4 launch note");
});

test("in-flight send uses and clears only the originating workbench's opening message", async () => {
const queryClient = new QueryClient();
parkOpeningMessage("tnt_bench_a", "Draft the Q4 launch note");

const calls: Array<{
readonly workbenchTenantId: string;
readonly participants: readonly WorkbenchParticipant[];
readonly content: string;
}> = [];
let resolveSend!: () => void;

const participantsA = [
{ id: "a1", kind: "agent" as const, name: "Worker", address: "run_a@bench-a.localhost" },
];
const pendingSend = dispatchOpeningMessage({
queryClient,
workbenchTenantId: "tnt_bench_a",
participants: participantsA,
content: "Draft the Q4 launch note",
send: (args) => {
calls.push(args);
return new Promise<void>((resolve) => {
resolveSend = resolve;
});
},
});

parkOpeningMessage("tnt_bench_b", "Audit the billing webhook");
expect(queryClient.getQueryData(workbenchKeys.pendingTurn("tnt_bench_a"))).toBeDefined();
expect(queryClient.getQueryData(workbenchKeys.pendingTurn("tnt_bench_b"))).toBeUndefined();

resolveSend();
await pendingSend;

expect(calls).toEqual([
{
workbenchTenantId: "tnt_bench_a",
participants: participantsA,
content: "Draft the Q4 launch note",
},
]);
expect(readOpeningMessage("tnt_bench_a")).toBeNull();
expect(readOpeningMessage("tnt_bench_b")).toBe("Audit the billing webhook");
});
});
131 changes: 109 additions & 22 deletions apps/web/src/bench/pending-opening-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// retry; it is only cleared once the send succeeds.

import { Button } from "@corbits/react-ui";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";

import { sendToWorkbench, type WorkbenchParticipant } from "@/chat/threads-api";
Expand All @@ -15,6 +15,58 @@ import "./pending-opening-message.css";

const CAP_MS = 60_000;

export async function dispatchOpeningMessage({
queryClient,
workbenchTenantId,
participants,
content,
send = sendToWorkbench,
}: {
readonly queryClient: QueryClient;
readonly workbenchTenantId: string;
readonly participants: readonly WorkbenchParticipant[];
readonly content: string;
readonly send?: typeof sendToWorkbench;
}): Promise<void> {
markTurnPending(queryClient, workbenchTenantId);
try {
await send({
workbenchTenantId,
participants,
content,
});
} catch (cause) {
queryClient.setQueryData(workbenchKeys.pendingTurn(workbenchTenantId), null);
throw cause;
}
clearOpeningMessage(workbenchTenantId);
await queryClient.invalidateQueries({ queryKey: workbenchKeys.scope(workbenchTenantId) });
}

export interface PendingOpeningMessageSession {
readonly workbenchTenantId: string;
readonly text: string | null;
readonly timedOut: boolean;
}

export function createPendingOpeningMessageSession(
workbenchTenantId: string,
): PendingOpeningMessageSession {
return {
workbenchTenantId,
text: readOpeningMessage(workbenchTenantId),
timedOut: false,
};
}

export function syncPendingOpeningMessageSession(
session: PendingOpeningMessageSession,
workbenchTenantId: string,
): PendingOpeningMessageSession {
if (session.workbenchTenantId === workbenchTenantId) return session;
return createPendingOpeningMessageSession(workbenchTenantId);
}

export function PendingOpeningMessage({
workbenchTenantId,
participants,
Expand All @@ -23,29 +75,49 @@ export function PendingOpeningMessage({
readonly participants: readonly WorkbenchParticipant[];
}) {
const queryClient = useQueryClient();
const [text, setText] = useState(() => readOpeningMessage(workbenchTenantId));
const [timedOut, setTimedOut] = useState(false);
const attempted = useRef(false);
const [session, setSession] = useState(() =>
createPendingOpeningMessageSession(workbenchTenantId),
);
const current = syncPendingOpeningMessageSession(session, workbenchTenantId);
if (current !== session) {
setSession(current);
}
const attemptedTenantId = useRef<string | null>(null);
const text = current.text;
const timedOut = current.timedOut;

useEffect(() => {
const timer = setTimeout(() => setTimedOut(true), CAP_MS);
const timer = setTimeout(
() =>
setSession((prev) =>
prev.workbenchTenantId === workbenchTenantId ? { ...prev, timedOut: true } : prev,
),
CAP_MS,
);
return () => clearTimeout(timer);
}, []);
}, [workbenchTenantId]);

const send = useMutation({
mutationFn: (content: string) =>
sendToWorkbench({
workbenchTenantId,
participants,
mutationFn: ({
targetTenantId,
targetParticipants,
content,
}: {
readonly targetTenantId: string;
readonly targetParticipants: readonly WorkbenchParticipant[];
readonly content: string;
}) =>
dispatchOpeningMessage({
queryClient,
workbenchTenantId: targetTenantId,
participants: targetParticipants,
content,
}),
onMutate: () => markTurnPending(queryClient, workbenchTenantId),
onSuccess: () => {
clearOpeningMessage(workbenchTenantId);
setText(null);
void queryClient.invalidateQueries({ queryKey: workbenchKeys.scope(workbenchTenantId) });
onSuccess: (_data, variables) => {
setSession((prev) =>
prev.workbenchTenantId === variables.targetTenantId ? { ...prev, text: null } : prev,
);
},
onError: () => queryClient.setQueryData(workbenchKeys.pendingTurn(workbenchTenantId), null),
});

const live = participants.some((p) => p.kind === "agent" && p.address.includes("@"));
Expand All @@ -54,9 +126,14 @@ export function PendingOpeningMessage({
// warns), and StrictMode double-renders would double-send without the
// ref guard.
useEffect(() => {
if (text === null || !live || attempted.current || send.isPending) return;
attempted.current = true;
send.mutate(text);
if (text === null || !live || attemptedTenantId.current === workbenchTenantId || send.isPending)
return;
attemptedTenantId.current = workbenchTenantId;
send.mutate({
targetTenantId: workbenchTenantId,
targetParticipants: participants,
content: text,
});
});

if (text === null) return null;
Expand All @@ -76,12 +153,22 @@ export function PendingOpeningMessage({
variant="ghost"
size="sm"
onClick={() => {
if (live) send.mutate(text);
else
if (live) {
send.mutate({
targetTenantId: workbenchTenantId,
targetParticipants: participants,
content: text,
});
} else {
void queryClient.invalidateQueries({
queryKey: workbenchKeys.participants(workbenchTenantId),
});
setTimedOut(false);
}
setSession((prev) =>
prev.workbenchTenantId === workbenchTenantId
? { ...prev, timedOut: false }
: prev,
);
}}
>
Retry
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/pages/new-workbench-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export function NewWorkbenchPickerRoute() {
create.mutate({ benchTenantId: selectedTenantId, openingMessage: text });
}

const composerDisabled = selectedTenantId === null;
const composerDisabled = selectedTenantId === null || workbenches.isPending;

return (
<div className="page-frame">
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/pages/workbench-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -527,5 +527,5 @@ export function WorkbenchRoute({ path }: { readonly path: string }) {
</PageShell>
);
}
return <Workbench workbenchTenantId={workbenchTenantId} />;
return <Workbench key={workbenchTenantId} workbenchTenantId={workbenchTenantId} />;
}
18 changes: 8 additions & 10 deletions e2e/lib/first-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ export async function clickText(page: Page, selector: string, label: string): Pr

/**
* 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.
* composer and its send button stay disabled until the workbench list has
* loaded, so typing or clicking earlier is silently lost.
*/
export async function startWorkbench(page: Page, message: string): Promise<void> {
await page.waitForFunction(
Expand All @@ -33,14 +33,12 @@ export async function startWorkbench(page: Page, message: string): Promise<void>
{ 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']");
const sendScript = `(() => {
const send = document.querySelector("button[aria-label='Start this workbench']");
return send instanceof HTMLButtonElement && !send.disabled ? send : null;
})()`;
await page.waitForFunction(sendScript, { timeout: STEP_TIMEOUT });
await page.evaluate(`(${sendScript}).click()`);
}

export async function runFirstRunFlow(page: Page, origin: string): Promise<void> {
Expand Down
Loading