From dfe4e70204ab45ecb258602dabd68cba3d9318e4 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 29 Sep 2026 22:38:17 -0700 Subject: [PATCH] fix(web): a new workbench's first message is never dropped (CL-9585) --- .../web/src/bench/pending-opening-message.tsx | 79 +++++++++++++++++++ apps/web/src/opening-message.ts | 31 ++++++++ apps/web/src/pages/new-workbench-picker.tsx | 8 +- apps/web/src/pages/workbench-page.tsx | 5 ++ apps/web/src/workbench-create.ts | 29 ++----- 5 files changed, 126 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/bench/pending-opening-message.tsx create mode 100644 apps/web/src/opening-message.ts diff --git a/apps/web/src/bench/pending-opening-message.tsx b/apps/web/src/bench/pending-opening-message.tsx new file mode 100644 index 000000000..2850368b5 --- /dev/null +++ b/apps/web/src/bench/pending-opening-message.tsx @@ -0,0 +1,79 @@ +// The first message of a new workbench, shown as a pending own bubble until +// the agent is live and the send lands. Past the cap it stays here with a +// retry; it is only cleared once the send succeeds. + +import { Button } from "@corbits/react-ui"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; + +import { sendToWorkbench, type WorkbenchParticipant } from "@/chat/threads-api"; +import { workbenchKeys } from "../chat-path"; +import { clearOpeningMessage, readOpeningMessage } from "../opening-message"; + +const CAP_MS = 60_000; + +export function PendingOpeningMessage({ + workbenchTenantId, + participants, +}: { + readonly workbenchTenantId: string; + readonly participants: readonly WorkbenchParticipant[]; +}) { + const queryClient = useQueryClient(); + const [text, setText] = useState(() => readOpeningMessage(workbenchTenantId)); + const [timedOut, setTimedOut] = useState(false); + const attempted = useRef(false); + + useEffect(() => { + const timer = setTimeout(() => setTimedOut(true), CAP_MS); + return () => clearTimeout(timer); + }, []); + + const send = useMutation({ + mutationFn: (content: string) => sendToWorkbench({ workbenchTenantId, participants, content }), + onSuccess: () => { + clearOpeningMessage(workbenchTenantId); + setText(null); + void queryClient.invalidateQueries({ queryKey: workbenchKeys.scope(workbenchTenantId) }); + }, + }); + + if (text === null) return null; + + const live = participants.some((p) => p.kind === "agent" && p.address.includes("@")); + if (live && !attempted.current && !send.isPending) { + attempted.current = true; + send.mutate(text); + } + const failed = send.isError || (timedOut && !live); + + return ( +
+
+
{text}
+ {failed ? ( +

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

+ ) : ( +

Sending once the agent is ready…

+ )} +
+
+ ); +} diff --git a/apps/web/src/opening-message.ts b/apps/web/src/opening-message.ts new file mode 100644 index 000000000..ccccba353 --- /dev/null +++ b/apps/web/src/opening-message.ts @@ -0,0 +1,31 @@ +// A new workbench's first message outlives the /new page: it is parked here +// until the workbench's agent is live and the send lands, so it is never lost. + +import { reportError } from "@corbits/error-sink"; + +const key = (tenantId: string): string => `workbench-opening-message:${tenantId}`; + +export function parkOpeningMessage(tenantId: string, text: string): void { + try { + sessionStorage.setItem(key(tenantId), text); + } catch (cause) { + reportError(cause, { operation: "opening_message_park", tenantId }); + } +} + +export function readOpeningMessage(tenantId: string): string | null { + try { + return sessionStorage.getItem(key(tenantId)); + } catch (cause) { + reportError(cause, { operation: "opening_message_read", tenantId }); + return null; + } +} + +export function clearOpeningMessage(tenantId: string): void { + try { + sessionStorage.removeItem(key(tenantId)); + } catch (cause) { + reportError(cause, { operation: "opening_message_clear", tenantId }); + } +} diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx index 5a70f3e4f..cb12b07a4 100644 --- a/apps/web/src/pages/new-workbench-picker.tsx +++ b/apps/web/src/pages/new-workbench-picker.tsx @@ -26,11 +26,9 @@ const GENERIC_CREATE_FAILURE = "Something went wrong creating this workbench. Tr export function describeWorkbenchCreateFailure(cause: unknown, refId?: string): string { if (!(cause instanceof WorkbenchCreateError)) return GENERIC_CREATE_FAILURE; const message = - cause.stage === "opening-message" - ? "Workbench created, but we couldn't send the opening message. Try again from the workbench." - : cause.stage === "deploy" - ? "Workbench created, but its agent couldn't be deployed into it. Try again from the workbench." - : GENERIC_CREATE_FAILURE; + cause.stage === "deploy" + ? "Workbench created, but its agent couldn't be deployed into it. Try again from the workbench." + : GENERIC_CREATE_FAILURE; return refId === undefined ? message : `${message} Reference: ${refId}`; } diff --git a/apps/web/src/pages/workbench-page.tsx b/apps/web/src/pages/workbench-page.tsx index 6b47b8736..ee7a71c13 100644 --- a/apps/web/src/pages/workbench-page.tsx +++ b/apps/web/src/pages/workbench-page.tsx @@ -26,6 +26,7 @@ import { import { VoiceOverlay } from "../voice/voice-overlay"; import { BenchDrawer } from "../bench/bench-drawer"; import { BenchPill } from "../bench/bench-pill"; +import { PendingOpeningMessage } from "../bench/pending-opening-message"; import { ArtifactsTab } from "../bench/artifacts-tab"; import { useWorkerStatus } from "../worker-status"; import { GrantsTab } from "../bench/grants-tab"; @@ -283,6 +284,10 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string } onReply={(target) => setOpenThread(target.messageId)} /> ))} + {send.error === null ? null : (

{errorText(send.error)}

diff --git a/apps/web/src/workbench-create.ts b/apps/web/src/workbench-create.ts index e5ef35c0f..9c2c49875 100644 --- a/apps/web/src/workbench-create.ts +++ b/apps/web/src/workbench-create.ts @@ -1,17 +1,18 @@ // An agent joins a workbench by being deployed into the child tenant // itself — its deploy route rejects the parent's inherited asset. -import { isMyraAgent, listWorkbenchParticipants, sendToWorkbench } from "@/chat/threads-api"; +import { isMyraAgent } from "@/chat/threads-api"; import { agentSlugFromSourceAssetName, deployAgentSource } from "./agent-deploy"; import { readAgentSource } from "./agent-source-read"; import { deployMyraSource } from "./myra-deploy"; +import { parkOpeningMessage } from "./opening-message"; import { createFetchStockHub } from "./needs-converge"; import { resolveExistingOffering } from "./onboarding/provider-connect-step"; export class WorkbenchCreateError extends Error { constructor( message: string, - readonly stage: "create" | "deploy" | "opening-message", + readonly stage: "create" | "deploy", /** Set once the workbench tenant exists, so a later-stage failure can * still land the person in the workbench it created. */ readonly tenantId?: string, @@ -71,6 +72,11 @@ export async function createWorkbench(input: CreateWorkbenchInput): Promise participant.kind === "agent"); - // A deployment's run address exists only once the deploy settles; a - // workbench whose agent has not surfaced yet keeps the opening message - // for the person to send from the workbench itself. - if (agents.length > 0) { - await sendToWorkbench({ - workbenchTenantId: tenantId, - participants, - content: input.openingMessage, - }); - } - } catch (cause) { - throw failure(cause, "opening-message", tenantId); - } - } - return tenantId; }